repaire sentences
This commit is contained in:
@@ -16,6 +16,7 @@ pip 安装依赖后即可直接播放视频。
|
|||||||
缓存结果;生成过程有进度条。
|
缓存结果;生成过程有进度条。
|
||||||
- 画面字幕:句边界就绪后,当前句会叠在视频画面底部。可勾选“中文字幕”
|
- 画面字幕:句边界就绪后,当前句会叠在视频画面底部。可勾选“中文字幕”
|
||||||
(快捷键 `C`)显示或关闭中文翻译;翻译由服务端生成并缓存。
|
(快捷键 `C`)显示或关闭中文翻译;翻译由服务端生成并缓存。
|
||||||
|
- 导出字幕:句边界就绪后可点“导出字幕”,默认保存为 UTF-8(带 BOM)的 `.ass`(中英同一条字幕避免重叠),也可另存 `.srt`。
|
||||||
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
|
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
|
||||||
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
|
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
|
||||||
- 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在
|
- 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在
|
||||||
|
|||||||
591
media_player.py
591
media_player.py
@@ -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()
|
|
||||||
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)
|
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:
|
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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -122,8 +122,11 @@
|
|||||||
<h2 id="sentence-title">句子文本</h2>
|
<h2 id="sentence-title">句子文本</h2>
|
||||||
<span id="sentence-meta"></span>
|
<span id="sentence-meta"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
<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>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user