repaire sentences

This commit is contained in:
2026-10-21 19:39:04 +08:00
parent 176d722c77
commit 532d9900ba
13 changed files with 1233 additions and 60 deletions

View File

@@ -16,6 +16,7 @@ pip 安装依赖后即可直接播放视频。
缓存结果;生成过程有进度条。 缓存结果;生成过程有进度条。
- 画面字幕:句边界就绪后,当前句会叠在视频画面底部。可勾选“中文字幕” - 画面字幕:句边界就绪后,当前句会叠在视频画面底部。可勾选“中文字幕”
(快捷键 `C`)显示或关闭中文翻译;翻译由服务端生成并缓存。 (快捷键 `C`)显示或关闭中文翻译;翻译由服务端生成并缓存。
- 导出字幕:句边界就绪后可点“导出字幕”,默认保存为 UTF-8(带 BOM)的 `.ass`(中英同一条字幕避免重叠),也可另存 `.srt`。
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。 - AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。 - 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
- 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在 - 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在

View File

@@ -18,6 +18,7 @@
勾选“按句跳转”后,←/→ 也会自动播放上一句/下一句 勾选“按句跳转”后,←/→ 也会自动播放上一句/下一句
C 开 / 关中文字幕 C 开 / 关中文字幕
E 讲解当前句子(AI) E 讲解当前句子(AI)
导出字幕 将当前中英文字幕保存为 SRT 文件
M 静音 / 恢复 M 静音 / 恢复
Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →) Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →)
N / P 下一首 / 上一首 N / P 下一首 / 上一首
@@ -50,9 +51,9 @@ import tkinter as tk
from tkinter import filedialog, messagebox, ttk from tkinter import filedialog, messagebox, ttk
try: try:
from PIL import Image as PILImage, ImageTk from PIL import Image as PILImage, ImageDraw, ImageFont, ImageTk
except ImportError: except ImportError:
PILImage = ImageTk = None PILImage = ImageDraw = ImageFont = ImageTk = None
try: try:
import vlc import vlc
@@ -80,6 +81,9 @@ SUPPORTED_EXTS = AUDIO_EXTS | VIDEO_EXTS
LOOP_LABELS = {"off": "循环: 关", "one": "循环: 单曲", "list": "循环: 列表"} LOOP_LABELS = {"off": "循环: 关", "one": "循环: 单曲", "list": "循环: 列表"}
SPEED_PRESETS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] 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 = 10
SEEK_STEP_BIG = 60 SEEK_STEP_BIG = 60
DEFAULT_SERVER_URL = "https://videoservice.d1kt.cn" 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}" 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: class _BaseEngine:
"""播放内核统一接口。""" """播放内核统一接口。"""
@@ -287,6 +311,9 @@ class _BaseEngine:
"""返回当前应显示的帧(PIL 图像),没有新帧时返回 None。""" """返回当前应显示的帧(PIL 图像),没有新帧时返回 None。"""
return None return None
def set_display_size(self, width, height):
return None
def poll_events(self): def poll_events(self):
events = [] events = []
while True: while True:
@@ -306,7 +333,14 @@ class VlcEngine(_BaseEngine):
def __init__(self): def __init__(self):
super().__init__() 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: if IS_MAC:
args.append("--vout=macosx") args.append("--vout=macosx")
self.instance = vlc.Instance(args) self.instance = vlc.Instance(args)
@@ -550,8 +584,11 @@ class AvEngine(_BaseEngine):
self._video_stream = None self._video_stream = None
self._audio_stream = None self._audio_stream = None
self._video_deque = collections.deque() self._video_deque = collections.deque()
self._video_deque_max = 6 self._video_deque_max = 12
self._audio_queue = queue.Queue(maxsize=64) 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_leftover = None
self._audio_stream_obj = None self._audio_stream_obj = None
self._audio_ok = False self._audio_ok = False
@@ -571,9 +608,31 @@ class AvEngine(_BaseEngine):
self._clock_started = time.monotonic() self._clock_started = time.monotonic()
self._clock_running = False 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): def play(self, path):
self.stop() self.stop()
container = self._av.open(str(path)) container = self._open_container(path)
self._container = container self._container = container
videos = [s for s in container.streams if s.type == "video"] videos = [s for s in container.streams if s.type == "video"]
if videos: if videos:
@@ -585,6 +644,15 @@ class AvEngine(_BaseEngine):
) )
else: else:
self._video_stream = None 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._audio_stream = next((s for s in container.streams if s.type == "audio"), None)
self._gen += 1 self._gen += 1
self._eof = False self._eof = False
@@ -594,22 +662,28 @@ class AvEngine(_BaseEngine):
self._resampler = None self._resampler = None
self._audio_ok = False self._audio_ok = False
self._audio_broken = False self._audio_broken = False
self._prerolling = True
self._pending_video.clear()
if self._audio_stream is not None: if self._audio_stream is not None:
try: try:
self._audio_stream_obj = self._sd.OutputStream( kwargs = {
samplerate=self.AUDIO_RATE, "samplerate": self.AUDIO_RATE,
channels=2, "channels": 2,
dtype="float32", "dtype": "float32",
callback=self._audio_callback, "callback": self._audio_callback,
) "blocksize": 2048,
self._audio_stream_obj.start() }
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 self._audio_ok = True
except Exception: except Exception:
self._audio_stream_obj = None self._audio_stream_obj = None
self._audio_written = 0 self._audio_written = 0
self._clock_base = 0.0 self._clock_base = 0.0
self._clock_started = time.monotonic() self._clock_started = time.monotonic()
self._clock_running = self._audio_stream_obj is None self._clock_running = False
self._stop_flag = threading.Event() self._stop_flag = threading.Event()
self._thread = threading.Thread(target=self._decode_loop, daemon=True) self._thread = threading.Thread(target=self._decode_loop, daemon=True)
self._thread.start() self._thread.start()
@@ -657,13 +731,14 @@ class AvEngine(_BaseEngine):
self._drain_queue(self._audio_queue) self._drain_queue(self._audio_queue)
self._audio_leftover = None self._audio_leftover = None
self._audio_written = int(target * self.AUDIO_RATE) self._audio_written = int(target * self.AUDIO_RATE)
self._pending_video.clear()
self._prerolling = True
self._clock_base = target self._clock_base = target
self._clock_started = time.monotonic() self._clock_started = time.monotonic()
self._ended = False self._ended = False
if self._audio_stream_obj is not None: if self._audio_stream_obj is not None:
try: try:
self._audio_stream_obj.stop() self._audio_stream_obj.stop()
self._audio_stream_obj.start()
except Exception: except Exception:
pass pass
if self._thread is None or not self._thread.is_alive(): if self._thread is None or not self._thread.is_alive():
@@ -677,11 +752,22 @@ class AvEngine(_BaseEngine):
def get_time(self): def get_time(self):
if not self.is_active(): if not self.is_active():
return -1 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: with self._lock:
return int(self._audio_written * 1000.0 / self.AUDIO_RATE) return int(self._audio_written * 1000.0 / self.AUDIO_RATE)
return int(self._noaudio_pos() * 1000.0) 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=""): def _disable_audio(self, reason=""):
"""音频不可用时降级为无声播放,视频播放不受影响。""" """音频不可用时降级为无声播放,视频播放不受影响。"""
stream = self._audio_stream_obj stream = self._audio_stream_obj
@@ -735,7 +821,7 @@ class AvEngine(_BaseEngine):
return False return False
def get_frame(self): def get_frame(self):
if not self.is_active(): if not self.is_active() or self._prerolling:
return None return None
pos = self.get_time() / 1000.0 pos = self.get_time() / 1000.0
latest = None latest = None
@@ -745,12 +831,78 @@ class AvEngine(_BaseEngine):
if gen != self._gen: if gen != self._gen:
self._video_deque.popleft() self._video_deque.popleft()
continue continue
if secs > pos + 0.05: if secs > pos + 0.02:
break break
latest = img latest = img
self._video_deque.popleft() self._video_deque.popleft()
return latest 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): def poll_events(self):
events = super().poll_events() events = super().poll_events()
if self._audio_broken and self._audio_stream_obj is not None: if self._audio_broken and self._audio_stream_obj is not None:
@@ -770,6 +922,37 @@ class AvEngine(_BaseEngine):
def release(self): def release(self):
self.stop() 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): def _decode_loop(self):
try: try:
container = self._container container = self._container
@@ -778,10 +961,15 @@ class AvEngine(_BaseEngine):
streams = [s for s in (video, audio) if s is not None] streams = [s for s in (video, audio) if s is not None]
demux = container.demux(*streams) demux = container.demux(*streams)
while not self._stop_flag.is_set(): 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: if self._seek_request is not None:
with self._lock: with self._lock:
target = self._seek_request target = self._seek_request
self._seek_request = None self._seek_request = None
self._pending_video.clear()
self._prerolling = True
seek_stream = video if video is not None else audio seek_stream = video if video is not None else audio
if seek_stream is not None: if seek_stream is not None:
offset = int(round(target / float(seek_stream.time_base))) offset = int(round(target / float(seek_stream.time_base)))
@@ -793,9 +981,19 @@ class AvEngine(_BaseEngine):
self._resampler = None self._resampler = None
continue continue
gen = self._gen 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: try:
packet = next(demux) packet = next(demux)
except StopIteration: 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 self._eof = True
break break
except Exception: except Exception:
@@ -804,28 +1002,22 @@ class AvEngine(_BaseEngine):
if self._stop_flag.is_set(): if self._stop_flag.is_set():
break break
if packet.stream.type == "video" and video is not None and packet.size: if packet.stream.type == "video" and video is not None and packet.size:
try: if self._video_space() and not self._pending_video:
frames = packet.decode() self._decode_video_packet(packet, gen)
except Exception: elif len(self._pending_video) < 90:
continue self._pending_video.append(packet)
for frame in frames: else:
if self._stop_flag.is_set(): while (
break not self._stop_flag.is_set()
if frame.pts is None: and gen == self._gen
continue and len(self._pending_video) >= 90
secs = float(frame.pts * video.time_base) ):
if secs + 0.05 < self._seek_after: if self._video_space():
continue self._decode_video_packet(self._pending_video.popleft(), gen)
try: else:
img = frame.to_image() time.sleep(0.005)
except Exception: if not self._stop_flag.is_set() and gen == self._gen:
continue self._pending_video.append(packet)
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)
elif packet.stream.type == "audio" and audio is not None and packet.size: elif packet.stream.type == "audio" and audio is not None and packet.size:
if not self._audio_ok: if not self._audio_ok:
continue continue
@@ -835,6 +1027,7 @@ class AvEngine(_BaseEngine):
self._disable_audio("音频解码出错(%s)" % exc) self._disable_audio("音频解码出错(%s)" % exc)
if self._resampler is not None and not self._stop_flag.is_set(): if self._resampler is not None and not self._stop_flag.is_set():
self._flush_resampler() self._flush_resampler()
self._maybe_finish_preroll()
except Exception as exc: except Exception as exc:
self._eof = True self._eof = True
print("解码线程异常退出:", exc) print("解码线程异常退出:", exc)
@@ -1044,6 +1237,14 @@ class MediaPlayerApp(tk.Tk):
self._translation_error = "" self._translation_error = ""
self._subtitle_srt_key = None self._subtitle_srt_key = None
self._overlay_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() self.engine = self._create_engine()
if self.engine is None: if self.engine is None:
@@ -1148,7 +1349,7 @@ class MediaPlayerApp(tk.Tk):
text="", text="",
fg="#ffffff", fg="#ffffff",
bg="#000000", bg="#000000",
font=("Helvetica", 16, "bold"), font=("Helvetica", SUBTITLE_EN_FONT_SIZE, "bold"),
wraplength=720, wraplength=720,
justify="center", justify="center",
) )
@@ -1158,7 +1359,7 @@ class MediaPlayerApp(tk.Tk):
text="", text="",
fg="#ffd54a", fg="#ffd54a",
bg="#000000", bg="#000000",
font=("Helvetica", 14), font=("Helvetica", SUBTITLE_ZH_FONT_SIZE),
wraplength=720, wraplength=720,
justify="center", justify="center",
) )
@@ -1259,6 +1460,15 @@ class MediaPlayerApp(tk.Tk):
takefocus=0, takefocus=0,
) )
self.btn_ai_explain.pack(side="left", padx=(12, 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("<space>", self._on_space)
self.sentence_var = tk.StringVar(value="") self.sentence_var = tk.StringVar(value="")
self.sentence_label = ttk.Label( self.sentence_label = ttk.Label(
controls, controls,
@@ -1394,24 +1604,37 @@ class MediaPlayerApp(tk.Tk):
self._on_media_end() self._on_media_end()
elif event == "error": elif event == "error":
self.status_var.set("无法播放该文件,请检查文件格式或 VLC 是否已安装") 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() img = self.engine.get_frame()
if img is not None: if img is not None:
self._show_frame(img) self._show_frame(img)
pos = self.engine.get_time() pos = self.engine.get_time()
length = self.engine.get_length() length = self.engine.get_length()
if pos >= 0 and not self._seeking: 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: if length > 0:
self.seekbar.set_frac(pos / length) self.seekbar.set_frac(pos / length)
sentence = self._current_sentence() sentence = self._current_sentence()
text = str(sentence.get("text") or "").strip() if sentence else "" sentence_text = str(sentence.get("text") or "").strip() if sentence else ""
self.sentence_var.set(text) if sentence_text != self.sentence_var.get():
self.sentence_var.set(sentence_text)
self._update_on_screen_subtitles() self._update_on_screen_subtitles()
playing = self.engine.is_playing() playing = self.engine.is_playing()
self.btn_play.config(text="暂停" if playing else "播放") play_label = "暂停" if playing else "播放"
self.after(33, self._tick) 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: if ImageTk is None:
return return
canvas = getattr(self, "video_canvas", None) 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))) size = (max(1, int(iw * scale)), max(1, int(ih * scale)))
if size != img.size: if size != img.size:
img = img.resize(size, PILImage.Resampling.BILINEAR) img = img.resize(size, PILImage.Resampling.BILINEAR)
self._photo = ImageTk.PhotoImage(img) if store:
canvas.delete("all") self._last_video_frame = img
canvas.create_image(width // 2, height // 2, image=self._photo, anchor="center") display = self._compose_subtitles(img)
if getattr(self, "subtitle_overlay", None) is not None: self._photo = ImageTk.PhotoImage(display)
self.subtitle_overlay.lift() 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): def open_files(self):
filetypes = [ filetypes = [
@@ -1629,6 +1858,11 @@ class MediaPlayerApp(tk.Tk):
if canvas is not None: if canvas is not None:
canvas.delete("all") canvas.delete("all")
self._photo = None 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.video_hint.place(relx=0.5, rely=0.5, anchor="center")
self._update_sentence_controls() self._update_sentence_controls()
self._update_status() self._update_status()
@@ -1826,6 +2060,9 @@ class MediaPlayerApp(tk.Tk):
state="normal", state="normal",
text="重新生成" if has_boundaries else "生成句边界", 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): def _set_progress(self, value=None, message=None, indeterminate=False):
if not hasattr(self, "boundary_progress"): if not hasattr(self, "boundary_progress"):
@@ -1877,6 +2114,68 @@ class MediaPlayerApp(tk.Tk):
self._update_on_screen_subtitles() self._update_on_screen_subtitles()
self._sync_vlc_subtitles(force=True) 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): def generate_sentence_boundaries(self):
path = self._current_path() path = self._current_path()
if path is None: if path is None:
@@ -2213,6 +2512,139 @@ 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 _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): def _on_video_resize(self, event):
if event.widget is not getattr(self, "video_frame", None): if event.widget is not getattr(self, "video_frame", None):
return return
@@ -2251,10 +2683,22 @@ class MediaPlayerApp(tk.Tk):
chinese = "正在翻译中文字幕…" chinese = "正在翻译中文字幕…"
else: else:
chinese = "暂无中文字幕" chinese = "暂无中文字幕"
self._subtitle_english = english
self._subtitle_chinese = chinese
key = (english, chinese, bool(self.show_chinese_subtitles.get())) key = (english, chinese, bool(self.show_chinese_subtitles.get()))
if key == self._overlay_key: changed = key != self._overlay_key
return
self._overlay_key = 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: try:
self.subtitle_en.configure(text=english) self.subtitle_en.configure(text=english)
if chinese: if chinese:
@@ -2290,6 +2734,45 @@ class MediaPlayerApp(tk.Tk):
number += 1 number += 1
return "\n\n".join(blocks) + ("\n" if blocks else "") 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): def _sync_vlc_subtitles(self, force=False):
if not isinstance(self.engine, VlcEngine): if not isinstance(self.engine, VlcEngine):
return return

View File

@@ -59,6 +59,7 @@ POST /api/v1/videos/{sha256}/sentences/{index}/assessments
POST /api/v1/admin/videos POST /api/v1/admin/videos
PUT /api/v1/admin/videos/raw PUT /api/v1/admin/videos/raw
POST /api/v1/admin/videos/{sha256}/process POST /api/v1/admin/videos/{sha256}/process
POST /api/v1/admin/videos/{sha256}/repair-gaps
GET /api/v1/admin/videos/{sha256}/subtitle-translation GET /api/v1/admin/videos/{sha256}/subtitle-translation
POST /api/v1/admin/videos/{sha256}/subtitle-translation POST /api/v1/admin/videos/{sha256}/subtitle-translation
PUT /api/v1/admin/videos/{sha256}/sentences/{index} PUT /api/v1/admin/videos/{sha256}/sentences/{index}

View File

@@ -43,6 +43,8 @@ from .models import (
CourseSummary, CourseSummary,
CourseTitleUpdate, CourseTitleUpdate,
SentenceBoundaryAdjust, SentenceBoundaryAdjust,
SentenceGapRepairRequest,
SentenceGapRepairResponse,
SentenceBoundary, SentenceBoundary,
SentenceBoundaryDocument, SentenceBoundaryDocument,
SentenceExplanationRequest, SentenceExplanationRequest,
@@ -688,6 +690,34 @@ def create_app(
background_tasks.add_task(_process_safely, processor, video_hash) background_tasks.add_task(_process_safely, processor, video_hash)
return {"video_hash": video_hash.lower(), "status": "processing_queued"} 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( @application.get(
"/api/v1/admin/videos/{video_hash}/subtitle-translation", "/api/v1/admin/videos/{video_hash}/subtitle-translation",
response_model=AdminSubtitleTranslationResponse, response_model=AdminSubtitleTranslationResponse,

View File

@@ -185,6 +185,23 @@ class SentenceBoundaryAdjust(BaseModel):
delta_ms: int = Field(ge=-10_000, le=10_000) 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): class TextSubstitution(BaseModel):
expected: str expected: str
actual: str actual: str

View File

@@ -4,7 +4,7 @@ import subprocess
import tempfile import tempfile
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Sequence, Tuple
from .audio_metrics import ( from .audio_metrics import (
AudioAnalysisError, AudioAnalysisError,
@@ -29,6 +29,10 @@ from .transcription import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MOSS_ALGORITHM_VERSION = "moss-punctuation-v4" MOSS_ALGORITHM_VERSION = "moss-punctuation-v4"
SILENCE_ALGORITHM_VERSION = "silence-rms-v1" 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" _CUES_ID = b"\x1e\x53\xb6\x80"
_CLUSTER_ID = b"\x1f\x43\xb6\x75" _CLUSTER_ID = b"\x1f\x43\xb6\x75"
_HEADER_SCAN_SIZE = 64 * 1024 _HEADER_SCAN_SIZE = 64 * 1024
@@ -173,6 +177,106 @@ class VideoProcessor:
) )
self.repository.mark_oss_uploaded(document.video_hash, remote_url) 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: def extract_audio(media_path: Path, output_path: Path) -> None:
ffmpeg = shutil.which("ffmpeg") ffmpeg = shutil.which("ffmpeg")
@@ -241,6 +345,82 @@ def extract_audio_segment(
raise RuntimeError(f"Could not extract sentence audio: {message[-2000:]}") 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( def split_sentence_text(
*, *,
transcriber: Transcriber, transcriber: Transcriber,

View File

@@ -544,6 +544,53 @@ class VideoRepository:
sentences=[_sentence_from_row(row) for row in rows], 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( def update_sentence_text(
self, self,
video_hash: str, video_hash: str,

View File

@@ -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, 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; } th { position: sticky; top: 0; z-index: 1; color: #44515b; background: #edf1f3; font-weight: 600; }
tr:last-child td { border-bottom: 0; } 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 { display: flex; gap: 6px; white-space: nowrap; }
.actions button, .actions a { min-height: 30px; padding: 5px 9px; font-size: 12px; } .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; } .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::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 { 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 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 { 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 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; } .sentence-scroll select { min-width: 90px; }

View File

@@ -122,7 +122,10 @@
<h2 id="sentence-title">句子文本</h2> <h2 id="sentence-title">句子文本</h2>
<span id="sentence-meta"></span> <span id="sentence-meta"></span>
</div> </div>
<button id="close-dialog" class="icon-button" type="button" aria-label="关闭">X</button> <div class="dialog-heading-actions">
<button id="repair-gaps" type="button">修复空白</button>
<button id="close-dialog" class="icon-button" type="button" aria-label="关闭">X</button>
</div>
</div> </div>
<div class="sentence-scroll"> <div class="sentence-scroll">
<table> <table>
@@ -134,6 +137,6 @@
</div> </div>
</dialog> </dialog>
<script src="/static/admin.js?v=admin-retranslate-v1" defer></script> <script src="/static/admin.js?v=admin-repair-gaps-v1" defer></script>
</body> </body>
</html> </html>

View File

@@ -1,8 +1,11 @@
const GAP_REPAIR_MIN_MS = 8000;
const state = { const state = {
adminKey: localStorage.getItem("oralTrainerAdminKey") || "", adminKey: localStorage.getItem("oralTrainerAdminKey") || "",
videos: [], videos: [],
users: [], users: [],
retranslateVideo: null, retranslateVideo: null,
sentenceVideo: null,
}; };
const elements = { const elements = {
@@ -23,6 +26,7 @@ const elements = {
sentenceMeta: document.querySelector("#sentence-meta"), sentenceMeta: document.querySelector("#sentence-meta"),
sentenceRows: document.querySelector("#sentence-rows"), sentenceRows: document.querySelector("#sentence-rows"),
closeDialog: document.querySelector("#close-dialog"), closeDialog: document.querySelector("#close-dialog"),
repairGaps: document.querySelector("#repair-gaps"),
retranslateDialog: document.querySelector("#retranslate-dialog"), retranslateDialog: document.querySelector("#retranslate-dialog"),
retranslateTitle: document.querySelector("#retranslate-title"), retranslateTitle: document.querySelector("#retranslate-title"),
retranslateMeta: document.querySelector("#retranslate-meta"), retranslateMeta: document.querySelector("#retranslate-meta"),
@@ -45,6 +49,9 @@ elements.refresh.addEventListener("click", () => {
loadUsers(); loadUsers();
}); });
elements.closeDialog.addEventListener("click", () => elements.dialog.close()); 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.closeRetranslateDialog.addEventListener("click", () => elements.retranslateDialog.close());
elements.retranslateForm.addEventListener("submit", submitRetranslate); elements.retranslateForm.addEventListener("submit", submitRetranslate);
elements.uploadForm.addEventListener("submit", uploadVideo); elements.uploadForm.addEventListener("submit", uploadVideo);
@@ -192,6 +199,7 @@ function actionCell(video) {
actions.append(play); actions.append(play);
actions.append(button("改标题", () => renameVideo(video))); actions.append(button("改标题", () => renameVideo(video)));
actions.append(button("句子", () => openSentences(video))); actions.append(button("句子", () => openSentences(video)));
actions.append(button("修复空白", () => repairGaps(video)));
actions.append(button("重新翻译", () => openRetranslate(video))); actions.append(button("重新翻译", () => openRetranslate(video)));
actions.append(button("重新处理", () => reprocess(video.video_hash))); actions.append(button("重新处理", () => reprocess(video.video_hash)));
const remove = button("删除", () => deleteVideo(video)); const remove = button("删除", () => deleteVideo(video));
@@ -282,19 +290,61 @@ function readXhrError(request) {
async function openSentences(video) { async function openSentences(video) {
try { try {
const detail = await api(`/api/v1/videos/${video.video_hash}`); const detail = await api(`/api/v1/videos/${video.video_hash}`);
state.sentenceVideo = video;
elements.sentenceTitle.textContent = detail.video.title; elements.sentenceTitle.textContent = detail.video.title;
const sentences = detail.boundaries?.sentences || []; 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(); elements.sentenceRows.replaceChildren();
let gapIndex = 0;
for (const sentence of sentences) { 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)); 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(); if (!elements.dialog.open) elements.dialog.showModal();
} catch (error) { } catch (error) {
elements.serviceState.textContent = `句子加载失败: ${error.message}`; 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) { function sentenceRow(video, sentence, totalCount) {
const row = document.createElement("tr"); const row = document.createElement("tr");
const textArea = document.createElement("textarea"); 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) { async function deleteVideo(video) {
if (!window.confirm(`删除“${video.title}”?`)) return; if (!window.confirm(`删除“${video.title}”?`)) return;
try { try {

View File

@@ -1029,3 +1029,139 @@ def test_admin_can_update_course_title(tmp_path):
json={"title": "Missing course"}, json={"title": "Missing course"},
) )
assert missing_response.status_code == 404 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

View File

@@ -4,6 +4,7 @@ from sentence_api.processing import (
MOSS_ALGORITHM_VERSION, MOSS_ALGORITHM_VERSION,
VideoProcessor, VideoProcessor,
document_from_transcript, document_from_transcript,
find_sentence_gaps,
split_sentence_text, split_sentence_text,
) )
from sentence_api.models import SentenceBoundary, SentenceBoundaryDocument 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 document.sentences == []
assert repository.get_moss_segments(VIDEO_HASH) == [] 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."]

View File

@@ -116,6 +116,23 @@ class SubtitleTranslationService:
except (KeyError, ValueError, AITutorError): except (KeyError, ValueError, AITutorError):
logger.exception("Could not restart subtitle translation: video=%s", video_hash) 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: def admin_snapshot(self, video_hash: str) -> AdminSubtitleTranslationResponse | None:
current = self.current(video_hash) current = self.current(video_hash)
if current is None: if current is None: