repaire sentences
This commit is contained in:
593
media_player.py
593
media_player.py
@@ -18,6 +18,7 @@
|
||||
勾选“按句跳转”后,←/→ 也会自动播放上一句/下一句
|
||||
C 开 / 关中文字幕
|
||||
E 讲解当前句子(AI)
|
||||
导出字幕 将当前中英文字幕保存为 SRT 文件
|
||||
M 静音 / 恢复
|
||||
Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →)
|
||||
N / P 下一首 / 上一首
|
||||
@@ -50,9 +51,9 @@ import tkinter as tk
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
try:
|
||||
from PIL import Image as PILImage, ImageTk
|
||||
from PIL import Image as PILImage, ImageDraw, ImageFont, ImageTk
|
||||
except ImportError:
|
||||
PILImage = ImageTk = None
|
||||
PILImage = ImageDraw = ImageFont = ImageTk = None
|
||||
|
||||
try:
|
||||
import vlc
|
||||
@@ -80,6 +81,9 @@ SUPPORTED_EXTS = AUDIO_EXTS | VIDEO_EXTS
|
||||
|
||||
LOOP_LABELS = {"off": "循环: 关", "one": "循环: 单曲", "list": "循环: 列表"}
|
||||
SPEED_PRESETS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
|
||||
SUBTITLE_EN_FONT_SIZE = 32
|
||||
SUBTITLE_ZH_FONT_SIZE = 28
|
||||
SUBTITLE_BG_ALPHA = 51 # 20% opaque black
|
||||
SEEK_STEP = 10
|
||||
SEEK_STEP_BIG = 60
|
||||
DEFAULT_SERVER_URL = "https://videoservice.d1kt.cn"
|
||||
@@ -235,6 +239,26 @@ def _srt_timestamp(ms):
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
|
||||
|
||||
|
||||
def _ass_timestamp(ms):
|
||||
"""把毫秒格式化为 ASS 时间戳 H:MM:SS.cs。"""
|
||||
total = max(0, int(ms))
|
||||
hours, remainder = divmod(total, 3_600_000)
|
||||
minutes, remainder = divmod(remainder, 60_000)
|
||||
seconds, millis = divmod(remainder, 1000)
|
||||
return f"{hours}:{minutes:02d}:{seconds:02d}.{millis // 10:02d}"
|
||||
|
||||
|
||||
def _escape_ass_text(text: str) -> str:
|
||||
return (
|
||||
str(text)
|
||||
.replace("\\", "\\\\")
|
||||
.replace("{", "\\{")
|
||||
.replace("}", "\\}")
|
||||
.replace("\n", "\\N")
|
||||
.replace("\r", "")
|
||||
)
|
||||
|
||||
|
||||
class _BaseEngine:
|
||||
"""播放内核统一接口。"""
|
||||
|
||||
@@ -287,6 +311,9 @@ class _BaseEngine:
|
||||
"""返回当前应显示的帧(PIL 图像),没有新帧时返回 None。"""
|
||||
return None
|
||||
|
||||
def set_display_size(self, width, height):
|
||||
return None
|
||||
|
||||
def poll_events(self):
|
||||
events = []
|
||||
while True:
|
||||
@@ -306,7 +333,14 @@ class VlcEngine(_BaseEngine):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
args = ["--no-video-title-show", "--quiet"]
|
||||
args = [
|
||||
"--no-video-title-show",
|
||||
"--quiet",
|
||||
"--sub-text-scale=200",
|
||||
"--freetype-background-opacity=51",
|
||||
"--freetype-background-color=0x000000",
|
||||
"--freetype-opacity=255",
|
||||
]
|
||||
if IS_MAC:
|
||||
args.append("--vout=macosx")
|
||||
self.instance = vlc.Instance(args)
|
||||
@@ -550,8 +584,11 @@ class AvEngine(_BaseEngine):
|
||||
self._video_stream = None
|
||||
self._audio_stream = None
|
||||
self._video_deque = collections.deque()
|
||||
self._video_deque_max = 6
|
||||
self._audio_queue = queue.Queue(maxsize=64)
|
||||
self._video_deque_max = 12
|
||||
self._pending_video = collections.deque()
|
||||
self._audio_queue = queue.Queue(maxsize=128)
|
||||
self._display_size = None
|
||||
self._prerolling = False
|
||||
self._audio_leftover = None
|
||||
self._audio_stream_obj = None
|
||||
self._audio_ok = False
|
||||
@@ -571,9 +608,31 @@ class AvEngine(_BaseEngine):
|
||||
self._clock_started = time.monotonic()
|
||||
self._clock_running = False
|
||||
|
||||
def _open_container(self, path):
|
||||
try:
|
||||
from av.codec.hwaccel import HWAccel, hwdevices_available
|
||||
available = set(hwdevices_available() or [])
|
||||
if IS_MAC:
|
||||
order = ("videotoolbox",)
|
||||
elif IS_WIN:
|
||||
order = ("d3d11va", "dxva2", "cuda")
|
||||
else:
|
||||
order = ("vaapi", "cuda")
|
||||
for device in order:
|
||||
if device not in available:
|
||||
continue
|
||||
try:
|
||||
hw = HWAccel(device_type=device, allow_software_fallback=True)
|
||||
return self._av.open(str(path), hwaccel=hw)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return self._av.open(str(path))
|
||||
|
||||
def play(self, path):
|
||||
self.stop()
|
||||
container = self._av.open(str(path))
|
||||
container = self._open_container(path)
|
||||
self._container = container
|
||||
videos = [s for s in container.streams if s.type == "video"]
|
||||
if videos:
|
||||
@@ -585,6 +644,15 @@ class AvEngine(_BaseEngine):
|
||||
)
|
||||
else:
|
||||
self._video_stream = None
|
||||
if self._video_stream is not None:
|
||||
try:
|
||||
self._video_stream.thread_type = "AUTO"
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._video_stream.codec_context.thread_count = 0
|
||||
except Exception:
|
||||
pass
|
||||
self._audio_stream = next((s for s in container.streams if s.type == "audio"), None)
|
||||
self._gen += 1
|
||||
self._eof = False
|
||||
@@ -594,22 +662,28 @@ class AvEngine(_BaseEngine):
|
||||
self._resampler = None
|
||||
self._audio_ok = False
|
||||
self._audio_broken = False
|
||||
self._prerolling = True
|
||||
self._pending_video.clear()
|
||||
if self._audio_stream is not None:
|
||||
try:
|
||||
self._audio_stream_obj = self._sd.OutputStream(
|
||||
samplerate=self.AUDIO_RATE,
|
||||
channels=2,
|
||||
dtype="float32",
|
||||
callback=self._audio_callback,
|
||||
)
|
||||
self._audio_stream_obj.start()
|
||||
kwargs = {
|
||||
"samplerate": self.AUDIO_RATE,
|
||||
"channels": 2,
|
||||
"dtype": "float32",
|
||||
"callback": self._audio_callback,
|
||||
"blocksize": 2048,
|
||||
}
|
||||
try:
|
||||
self._audio_stream_obj = self._sd.OutputStream(latency="high", **kwargs)
|
||||
except TypeError:
|
||||
self._audio_stream_obj = self._sd.OutputStream(**kwargs)
|
||||
self._audio_ok = True
|
||||
except Exception:
|
||||
self._audio_stream_obj = None
|
||||
self._audio_written = 0
|
||||
self._clock_base = 0.0
|
||||
self._clock_started = time.monotonic()
|
||||
self._clock_running = self._audio_stream_obj is None
|
||||
self._clock_running = False
|
||||
self._stop_flag = threading.Event()
|
||||
self._thread = threading.Thread(target=self._decode_loop, daemon=True)
|
||||
self._thread.start()
|
||||
@@ -657,13 +731,14 @@ class AvEngine(_BaseEngine):
|
||||
self._drain_queue(self._audio_queue)
|
||||
self._audio_leftover = None
|
||||
self._audio_written = int(target * self.AUDIO_RATE)
|
||||
self._pending_video.clear()
|
||||
self._prerolling = True
|
||||
self._clock_base = target
|
||||
self._clock_started = time.monotonic()
|
||||
self._ended = False
|
||||
if self._audio_stream_obj is not None:
|
||||
try:
|
||||
self._audio_stream_obj.stop()
|
||||
self._audio_stream_obj.start()
|
||||
except Exception:
|
||||
pass
|
||||
if self._thread is None or not self._thread.is_alive():
|
||||
@@ -677,11 +752,22 @@ class AvEngine(_BaseEngine):
|
||||
def get_time(self):
|
||||
if not self.is_active():
|
||||
return -1
|
||||
if self._audio_stream_obj is not None:
|
||||
if self._prerolling:
|
||||
return int(self._seek_after * 1000.0)
|
||||
if self._audio_stream_obj is not None and not self._prerolling:
|
||||
with self._lock:
|
||||
return int(self._audio_written * 1000.0 / self.AUDIO_RATE)
|
||||
return int(self._noaudio_pos() * 1000.0)
|
||||
|
||||
def set_display_size(self, width, height):
|
||||
try:
|
||||
width = int(width)
|
||||
height = int(height)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if width >= 2 and height >= 2:
|
||||
self._display_size = (width, height)
|
||||
|
||||
def _disable_audio(self, reason=""):
|
||||
"""音频不可用时降级为无声播放,视频播放不受影响。"""
|
||||
stream = self._audio_stream_obj
|
||||
@@ -735,7 +821,7 @@ class AvEngine(_BaseEngine):
|
||||
return False
|
||||
|
||||
def get_frame(self):
|
||||
if not self.is_active():
|
||||
if not self.is_active() or self._prerolling:
|
||||
return None
|
||||
pos = self.get_time() / 1000.0
|
||||
latest = None
|
||||
@@ -745,12 +831,78 @@ class AvEngine(_BaseEngine):
|
||||
if gen != self._gen:
|
||||
self._video_deque.popleft()
|
||||
continue
|
||||
if secs > pos + 0.05:
|
||||
if secs > pos + 0.02:
|
||||
break
|
||||
latest = img
|
||||
self._video_deque.popleft()
|
||||
return latest
|
||||
|
||||
def _video_space(self):
|
||||
with self._lock:
|
||||
return len(self._video_deque) < self._video_deque_max
|
||||
|
||||
def _enqueue_video_frame(self, gen, secs, frame):
|
||||
if gen != self._gen:
|
||||
return
|
||||
img = self._frame_to_image(frame)
|
||||
if img is None:
|
||||
return
|
||||
with self._lock:
|
||||
if gen != self._gen:
|
||||
return
|
||||
if len(self._video_deque) >= self._video_deque_max:
|
||||
return
|
||||
self._video_deque.append((gen, secs, img))
|
||||
|
||||
def _frame_to_image(self, frame):
|
||||
try:
|
||||
fw = int(getattr(frame, "width", 0) or 0)
|
||||
fh = int(getattr(frame, "height", 0) or 0)
|
||||
if fw < 1 or fh < 1:
|
||||
return None
|
||||
tw, th = fw, fh
|
||||
display = self._display_size
|
||||
if display:
|
||||
dw, dh = display
|
||||
if dw >= 2 and dh >= 2:
|
||||
scale = min(dw / fw, dh / fh, 1.0)
|
||||
tw = max(2, int(fw * scale) // 2 * 2)
|
||||
th = max(2, int(fh * scale) // 2 * 2)
|
||||
if tw > 1920:
|
||||
th = max(2, int(th * 1920 / tw) // 2 * 2)
|
||||
tw = 1920
|
||||
fmt = getattr(getattr(frame, "format", None), "name", "") or ""
|
||||
if tw != fw or th != fh or fmt != "rgb24":
|
||||
frame = frame.reformat(width=tw, height=th, format="rgb24")
|
||||
return frame.to_image()
|
||||
except Exception:
|
||||
try:
|
||||
return frame.to_image()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _maybe_finish_preroll(self):
|
||||
if not self._prerolling or self._stop_flag.is_set() or self._paused:
|
||||
return
|
||||
with self._lock:
|
||||
video_ready = self._video_stream is None or len(self._video_deque) >= 6
|
||||
audio_ready = (not self._audio_ok) or self._audio_queue.qsize() >= 12
|
||||
if not video_ready or not audio_ready:
|
||||
return
|
||||
self._prerolling = False
|
||||
self._clock_base = self._seek_after
|
||||
self._clock_started = time.monotonic()
|
||||
self._audio_written = int(self._seek_after * self.AUDIO_RATE)
|
||||
if self._audio_stream_obj is not None:
|
||||
try:
|
||||
self._audio_stream_obj.start()
|
||||
except Exception:
|
||||
self._clock_running = True
|
||||
return
|
||||
self._clock_running = False
|
||||
else:
|
||||
self._clock_running = True
|
||||
|
||||
def poll_events(self):
|
||||
events = super().poll_events()
|
||||
if self._audio_broken and self._audio_stream_obj is not None:
|
||||
@@ -770,6 +922,37 @@ class AvEngine(_BaseEngine):
|
||||
def release(self):
|
||||
self.stop()
|
||||
|
||||
def _decode_video_packet(self, packet, gen):
|
||||
video = self._video_stream
|
||||
if video is None:
|
||||
return
|
||||
try:
|
||||
frames = packet.decode()
|
||||
except Exception:
|
||||
return
|
||||
for frame in frames:
|
||||
if self._stop_flag.is_set() or gen != self._gen:
|
||||
return
|
||||
if frame.pts is None:
|
||||
continue
|
||||
secs = float(frame.pts * video.time_base)
|
||||
if secs + 0.05 < self._seek_after:
|
||||
continue
|
||||
while not self._stop_flag.is_set() and gen == self._gen:
|
||||
if self._paused:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
if self._video_space():
|
||||
self._enqueue_video_frame(gen, secs, frame)
|
||||
break
|
||||
time.sleep(0.003)
|
||||
|
||||
def _drain_pending_video(self, gen):
|
||||
while self._pending_video and not self._stop_flag.is_set() and gen == self._gen:
|
||||
if not self._video_space():
|
||||
return
|
||||
self._decode_video_packet(self._pending_video.popleft(), gen)
|
||||
|
||||
def _decode_loop(self):
|
||||
try:
|
||||
container = self._container
|
||||
@@ -778,10 +961,15 @@ class AvEngine(_BaseEngine):
|
||||
streams = [s for s in (video, audio) if s is not None]
|
||||
demux = container.demux(*streams)
|
||||
while not self._stop_flag.is_set():
|
||||
if self._paused and self._seek_request is None:
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
if self._seek_request is not None:
|
||||
with self._lock:
|
||||
target = self._seek_request
|
||||
self._seek_request = None
|
||||
self._pending_video.clear()
|
||||
self._prerolling = True
|
||||
seek_stream = video if video is not None else audio
|
||||
if seek_stream is not None:
|
||||
offset = int(round(target / float(seek_stream.time_base)))
|
||||
@@ -793,9 +981,19 @@ class AvEngine(_BaseEngine):
|
||||
self._resampler = None
|
||||
continue
|
||||
gen = self._gen
|
||||
self._maybe_finish_preroll()
|
||||
audio_low = self._audio_ok and self._audio_queue.qsize() < 12
|
||||
if not audio_low:
|
||||
self._drain_pending_video(gen)
|
||||
try:
|
||||
packet = next(demux)
|
||||
except StopIteration:
|
||||
while self._pending_video and not self._stop_flag.is_set() and gen == self._gen:
|
||||
if self._video_space():
|
||||
self._decode_video_packet(self._pending_video.popleft(), gen)
|
||||
else:
|
||||
time.sleep(0.005)
|
||||
self._maybe_finish_preroll()
|
||||
self._eof = True
|
||||
break
|
||||
except Exception:
|
||||
@@ -804,28 +1002,22 @@ class AvEngine(_BaseEngine):
|
||||
if self._stop_flag.is_set():
|
||||
break
|
||||
if packet.stream.type == "video" and video is not None and packet.size:
|
||||
try:
|
||||
frames = packet.decode()
|
||||
except Exception:
|
||||
continue
|
||||
for frame in frames:
|
||||
if self._stop_flag.is_set():
|
||||
break
|
||||
if frame.pts is None:
|
||||
continue
|
||||
secs = float(frame.pts * video.time_base)
|
||||
if secs + 0.05 < self._seek_after:
|
||||
continue
|
||||
try:
|
||||
img = frame.to_image()
|
||||
except Exception:
|
||||
continue
|
||||
while not self._stop_flag.is_set():
|
||||
with self._lock:
|
||||
if len(self._video_deque) < self._video_deque_max:
|
||||
self._video_deque.append((gen, secs, img))
|
||||
break
|
||||
time.sleep(0.005)
|
||||
if self._video_space() and not self._pending_video:
|
||||
self._decode_video_packet(packet, gen)
|
||||
elif len(self._pending_video) < 90:
|
||||
self._pending_video.append(packet)
|
||||
else:
|
||||
while (
|
||||
not self._stop_flag.is_set()
|
||||
and gen == self._gen
|
||||
and len(self._pending_video) >= 90
|
||||
):
|
||||
if self._video_space():
|
||||
self._decode_video_packet(self._pending_video.popleft(), gen)
|
||||
else:
|
||||
time.sleep(0.005)
|
||||
if not self._stop_flag.is_set() and gen == self._gen:
|
||||
self._pending_video.append(packet)
|
||||
elif packet.stream.type == "audio" and audio is not None and packet.size:
|
||||
if not self._audio_ok:
|
||||
continue
|
||||
@@ -835,6 +1027,7 @@ class AvEngine(_BaseEngine):
|
||||
self._disable_audio("音频解码出错(%s)" % exc)
|
||||
if self._resampler is not None and not self._stop_flag.is_set():
|
||||
self._flush_resampler()
|
||||
self._maybe_finish_preroll()
|
||||
except Exception as exc:
|
||||
self._eof = True
|
||||
print("解码线程异常退出:", exc)
|
||||
@@ -1044,6 +1237,14 @@ class MediaPlayerApp(tk.Tk):
|
||||
self._translation_error = ""
|
||||
self._subtitle_srt_key = None
|
||||
self._overlay_key = None
|
||||
self._subtitle_english = ""
|
||||
self._subtitle_chinese = ""
|
||||
self._last_video_frame = None
|
||||
self._subtitle_fonts = {}
|
||||
self._canvas_image_id = None
|
||||
self._subtitle_bar_cache = None
|
||||
self._last_play_label = None
|
||||
self._last_time_label = None
|
||||
|
||||
self.engine = self._create_engine()
|
||||
if self.engine is None:
|
||||
@@ -1148,7 +1349,7 @@ class MediaPlayerApp(tk.Tk):
|
||||
text="",
|
||||
fg="#ffffff",
|
||||
bg="#000000",
|
||||
font=("Helvetica", 16, "bold"),
|
||||
font=("Helvetica", SUBTITLE_EN_FONT_SIZE, "bold"),
|
||||
wraplength=720,
|
||||
justify="center",
|
||||
)
|
||||
@@ -1158,7 +1359,7 @@ class MediaPlayerApp(tk.Tk):
|
||||
text="",
|
||||
fg="#ffd54a",
|
||||
bg="#000000",
|
||||
font=("Helvetica", 14),
|
||||
font=("Helvetica", SUBTITLE_ZH_FONT_SIZE),
|
||||
wraplength=720,
|
||||
justify="center",
|
||||
)
|
||||
@@ -1259,6 +1460,15 @@ class MediaPlayerApp(tk.Tk):
|
||||
takefocus=0,
|
||||
)
|
||||
self.btn_ai_explain.pack(side="left", padx=(12, 0))
|
||||
self.btn_export_subtitles = ttk.Button(
|
||||
controls,
|
||||
text="导出字幕",
|
||||
command=self.export_subtitles,
|
||||
takefocus=0,
|
||||
state="disabled",
|
||||
)
|
||||
self.btn_export_subtitles.pack(side="left", padx=(12, 0))
|
||||
self.btn_export_subtitles.bind("<space>", self._on_space)
|
||||
self.sentence_var = tk.StringVar(value="")
|
||||
self.sentence_label = ttk.Label(
|
||||
controls,
|
||||
@@ -1394,24 +1604,37 @@ class MediaPlayerApp(tk.Tk):
|
||||
self._on_media_end()
|
||||
elif event == "error":
|
||||
self.status_var.set("无法播放该文件,请检查文件格式或 VLC 是否已安装")
|
||||
canvas = getattr(self, "video_canvas", None)
|
||||
if canvas is not None:
|
||||
setter = getattr(self.engine, "set_display_size", None)
|
||||
if setter is not None:
|
||||
setter(canvas.winfo_width(), canvas.winfo_height())
|
||||
img = self.engine.get_frame()
|
||||
if img is not None:
|
||||
self._show_frame(img)
|
||||
pos = self.engine.get_time()
|
||||
length = self.engine.get_length()
|
||||
if pos >= 0 and not self._seeking:
|
||||
self.time_var.set(f"{fmt_time(pos)} / {fmt_time(length)}")
|
||||
time_label = f"{fmt_time(pos)} / {fmt_time(length)}"
|
||||
if time_label != self._last_time_label:
|
||||
self._last_time_label = time_label
|
||||
self.time_var.set(time_label)
|
||||
if length > 0:
|
||||
self.seekbar.set_frac(pos / length)
|
||||
sentence = self._current_sentence()
|
||||
text = str(sentence.get("text") or "").strip() if sentence else ""
|
||||
self.sentence_var.set(text)
|
||||
sentence_text = str(sentence.get("text") or "").strip() if sentence else ""
|
||||
if sentence_text != self.sentence_var.get():
|
||||
self.sentence_var.set(sentence_text)
|
||||
self._update_on_screen_subtitles()
|
||||
playing = self.engine.is_playing()
|
||||
self.btn_play.config(text="暂停" if playing else "播放")
|
||||
self.after(33, self._tick)
|
||||
play_label = "暂停" if playing else "播放"
|
||||
if play_label != self._last_play_label:
|
||||
self._last_play_label = play_label
|
||||
self.btn_play.config(text=play_label)
|
||||
software_video = isinstance(self.engine, AvEngine) and self.engine.is_playing()
|
||||
self.after(16 if software_video else 50, self._tick)
|
||||
|
||||
def _show_frame(self, img):
|
||||
def _show_frame(self, img, store=True):
|
||||
if ImageTk is None:
|
||||
return
|
||||
canvas = getattr(self, "video_canvas", None)
|
||||
@@ -1428,11 +1651,17 @@ class MediaPlayerApp(tk.Tk):
|
||||
size = (max(1, int(iw * scale)), max(1, int(ih * scale)))
|
||||
if size != img.size:
|
||||
img = img.resize(size, PILImage.Resampling.BILINEAR)
|
||||
self._photo = ImageTk.PhotoImage(img)
|
||||
canvas.delete("all")
|
||||
canvas.create_image(width // 2, height // 2, image=self._photo, anchor="center")
|
||||
if getattr(self, "subtitle_overlay", None) is not None:
|
||||
self.subtitle_overlay.lift()
|
||||
if store:
|
||||
self._last_video_frame = img
|
||||
display = self._compose_subtitles(img)
|
||||
self._photo = ImageTk.PhotoImage(display)
|
||||
x = width // 2
|
||||
y = height // 2
|
||||
if self._canvas_image_id is None:
|
||||
self._canvas_image_id = canvas.create_image(x, y, image=self._photo, anchor="center")
|
||||
else:
|
||||
canvas.coords(self._canvas_image_id, x, y)
|
||||
canvas.itemconfig(self._canvas_image_id, image=self._photo)
|
||||
|
||||
def open_files(self):
|
||||
filetypes = [
|
||||
@@ -1629,6 +1858,11 @@ class MediaPlayerApp(tk.Tk):
|
||||
if canvas is not None:
|
||||
canvas.delete("all")
|
||||
self._photo = None
|
||||
self._canvas_image_id = None
|
||||
self._last_video_frame = None
|
||||
self._subtitle_bar_cache = None
|
||||
self._subtitle_english = ""
|
||||
self._subtitle_chinese = ""
|
||||
self.video_hint.place(relx=0.5, rely=0.5, anchor="center")
|
||||
self._update_sentence_controls()
|
||||
self._update_status()
|
||||
@@ -1826,6 +2060,9 @@ class MediaPlayerApp(tk.Tk):
|
||||
state="normal",
|
||||
text="重新生成" if has_boundaries else "生成句边界",
|
||||
)
|
||||
export_btn = getattr(self, "btn_export_subtitles", None)
|
||||
if export_btn is not None:
|
||||
export_btn.config(state="normal" if has_boundaries else "disabled")
|
||||
|
||||
def _set_progress(self, value=None, message=None, indeterminate=False):
|
||||
if not hasattr(self, "boundary_progress"):
|
||||
@@ -1877,6 +2114,68 @@ class MediaPlayerApp(tk.Tk):
|
||||
self._update_on_screen_subtitles()
|
||||
self._sync_vlc_subtitles(force=True)
|
||||
|
||||
def export_subtitles(self):
|
||||
path = self._current_path()
|
||||
if path is None:
|
||||
messagebox.showinfo("导出字幕", "请先打开并选择一个媒体文件。")
|
||||
return
|
||||
sentences = (self._boundaries.get(path) or {}).get("sentences") or []
|
||||
if not sentences:
|
||||
messagebox.showinfo("导出字幕", "请先生成句边界后再导出字幕。")
|
||||
return
|
||||
english_count = sum(1 for sentence in sentences if str(sentence.get("text") or "").strip())
|
||||
chinese_count = sum(1 for sentence in sentences if str(sentence.get("zh_text") or "").strip())
|
||||
if english_count == 0 and chinese_count == 0:
|
||||
messagebox.showinfo("导出字幕", "当前句边界没有可导出的字幕文本。")
|
||||
return
|
||||
include_chinese = chinese_count > 0
|
||||
if not include_chinese:
|
||||
if not messagebox.askyesno(
|
||||
"导出字幕",
|
||||
"还没有中文翻译,是否只导出英文字幕?\n可先打开“中文字幕”等待翻译完成后再导出双语字幕。",
|
||||
):
|
||||
return
|
||||
media = Path(path)
|
||||
suffix = "en-zh" if include_chinese else "en"
|
||||
default_name = f"{media.stem}.{suffix}.ass"
|
||||
destination = filedialog.asksaveasfilename(
|
||||
title="导出中英文字幕",
|
||||
defaultextension=".ass",
|
||||
initialdir=str(media.parent),
|
||||
initialfile=default_name,
|
||||
filetypes=[
|
||||
("ASS 字幕(推荐,QQPlayer 更清晰)", "*.ass"),
|
||||
("SubRip 字幕", "*.srt"),
|
||||
("所有文件", "*"),
|
||||
],
|
||||
)
|
||||
if not destination:
|
||||
return
|
||||
dest = Path(destination)
|
||||
if dest.suffix.lower() == ".srt":
|
||||
content = self._build_srt_content(sentences, include_chinese=include_chinese)
|
||||
else:
|
||||
if dest.suffix.lower() not in {".ass", ".ssa"}:
|
||||
dest = dest.with_suffix(".ass")
|
||||
content = self._build_ass_content(
|
||||
sentences,
|
||||
include_chinese=include_chinese,
|
||||
title=media.stem,
|
||||
)
|
||||
if not content.strip():
|
||||
messagebox.showinfo("导出字幕", "没有可写入字幕文件的内容。")
|
||||
return
|
||||
try:
|
||||
dest.write_text(content, encoding="utf-8-sig")
|
||||
except Exception as exc:
|
||||
messagebox.showerror("导出字幕", f"保存失败:{exc}")
|
||||
return
|
||||
destination = str(dest)
|
||||
note = ""
|
||||
if include_chinese and chinese_count < english_count:
|
||||
note = f"(其中 {chinese_count}/{english_count} 句已有中文)"
|
||||
self.status_var.set(f"字幕已导出:{Path(destination).name}{note}")
|
||||
|
||||
def generate_sentence_boundaries(self):
|
||||
path = self._current_path()
|
||||
if path is None:
|
||||
@@ -2213,6 +2512,139 @@ class MediaPlayerApp(tk.Tk):
|
||||
detail = str(detail)
|
||||
return detail if len(detail) <= 300 else detail[:297] + "..."
|
||||
|
||||
def _subtitle_font(self, size, bold=False):
|
||||
key = (size, bold)
|
||||
cached = self._subtitle_fonts.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if ImageFont is None:
|
||||
return None
|
||||
candidates = [
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||
"/Library/Fonts/Arial Unicode.ttf",
|
||||
"C:/Windows/Fonts/msyhbd.ttc" if bold else "C:/Windows/Fonts/msyh.ttc",
|
||||
"C:/Windows/Fonts/msyh.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||
]
|
||||
font = None
|
||||
for path in candidates:
|
||||
if not path or not Path(path).exists():
|
||||
continue
|
||||
try:
|
||||
font = ImageFont.truetype(path, size=size)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if font is None:
|
||||
try:
|
||||
font = ImageFont.load_default()
|
||||
except Exception:
|
||||
font = None
|
||||
self._subtitle_fonts[key] = font
|
||||
return font
|
||||
|
||||
@staticmethod
|
||||
def _wrap_subtitle_text(draw, text, font, max_width):
|
||||
if not text:
|
||||
return []
|
||||
lines = []
|
||||
for paragraph in str(text).splitlines() or [text]:
|
||||
current = ""
|
||||
for char in paragraph:
|
||||
trial = current + char
|
||||
bbox = draw.textbbox((0, 0), trial, font=font, stroke_width=2)
|
||||
if bbox[2] - bbox[0] <= max_width or not current:
|
||||
current = trial
|
||||
else:
|
||||
lines.append(current)
|
||||
current = char
|
||||
if current:
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
def _compose_subtitles(self, img):
|
||||
english = getattr(self, "_subtitle_english", "") or ""
|
||||
chinese = getattr(self, "_subtitle_chinese", "") or ""
|
||||
if (not english and not chinese) or PILImage is None or ImageDraw is None:
|
||||
return img
|
||||
overlay, bar_top = self._subtitle_overlay_bar(english, chinese, img.width, img.height)
|
||||
if overlay is None:
|
||||
return img
|
||||
rgb = img if img.mode == "RGB" else img.convert("RGB")
|
||||
out = rgb.copy()
|
||||
region = out.crop((0, bar_top, rgb.width, rgb.height)).convert("RGBA")
|
||||
composed = PILImage.alpha_composite(region, overlay)
|
||||
out.paste(composed.convert("RGB"), (0, bar_top))
|
||||
return out
|
||||
|
||||
def _subtitle_overlay_bar(self, english, chinese, width, height):
|
||||
key = (english, chinese, width, height)
|
||||
cached = self._subtitle_bar_cache
|
||||
if cached and cached[0] == key:
|
||||
return cached[1], cached[2]
|
||||
en_font = self._subtitle_font(SUBTITLE_EN_FONT_SIZE, bold=True)
|
||||
zh_font = self._subtitle_font(SUBTITLE_ZH_FONT_SIZE, bold=False)
|
||||
if en_font is None or zh_font is None:
|
||||
return None, 0
|
||||
measure = PILImage.new("RGBA", (max(1, width), 8), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(measure)
|
||||
max_width = max(40, int(width * 0.90))
|
||||
en_lines = self._wrap_subtitle_text(draw, english, en_font, max_width)
|
||||
zh_lines = self._wrap_subtitle_text(draw, chinese, zh_font, max_width)
|
||||
if not en_lines and not zh_lines:
|
||||
self._subtitle_bar_cache = (key, None, 0)
|
||||
return None, 0
|
||||
|
||||
def line_height(line, font):
|
||||
bbox = draw.textbbox((0, 0), line, font=font, stroke_width=2)
|
||||
return max(1, bbox[3] - bbox[1])
|
||||
|
||||
gap = 8
|
||||
padding_y = 14
|
||||
content_h = 0
|
||||
for line in en_lines:
|
||||
content_h += line_height(line, en_font) + gap
|
||||
for line in zh_lines:
|
||||
content_h += line_height(line, zh_font) + gap
|
||||
if content_h:
|
||||
content_h -= gap
|
||||
bar_h = min(height, content_h + padding_y * 2)
|
||||
bar_top = max(0, height - bar_h - max(8, int(height * 0.03)))
|
||||
overlay = PILImage.new("RGBA", (width, height - bar_top), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
draw.rectangle((0, 0, width, overlay.height), fill=(0, 0, 0, SUBTITLE_BG_ALPHA))
|
||||
y = padding_y
|
||||
for line in en_lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=en_font, stroke_width=2)
|
||||
x = max(0, (width - (bbox[2] - bbox[0])) // 2)
|
||||
draw.text(
|
||||
(x, y),
|
||||
line,
|
||||
font=en_font,
|
||||
fill=(255, 255, 255, 255),
|
||||
stroke_width=2,
|
||||
stroke_fill=(0, 0, 0, 255),
|
||||
)
|
||||
y += line_height(line, en_font) + gap
|
||||
for line in zh_lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=zh_font, stroke_width=2)
|
||||
x = max(0, (width - (bbox[2] - bbox[0])) // 2)
|
||||
draw.text(
|
||||
(x, y),
|
||||
line,
|
||||
font=zh_font,
|
||||
fill=(255, 213, 74, 255),
|
||||
stroke_width=2,
|
||||
stroke_fill=(0, 0, 0, 255),
|
||||
)
|
||||
y += line_height(line, zh_font) + gap
|
||||
self._subtitle_bar_cache = (key, overlay, bar_top)
|
||||
return overlay, bar_top
|
||||
|
||||
def _on_video_resize(self, event):
|
||||
if event.widget is not getattr(self, "video_frame", None):
|
||||
return
|
||||
@@ -2251,10 +2683,22 @@ class MediaPlayerApp(tk.Tk):
|
||||
chinese = "正在翻译中文字幕…"
|
||||
else:
|
||||
chinese = "暂无中文字幕"
|
||||
self._subtitle_english = english
|
||||
self._subtitle_chinese = chinese
|
||||
key = (english, chinese, bool(self.show_chinese_subtitles.get()))
|
||||
if key == self._overlay_key:
|
||||
return
|
||||
changed = key != self._overlay_key
|
||||
self._overlay_key = key
|
||||
canvas = getattr(self, "video_canvas", None)
|
||||
if canvas is not None and self._last_video_frame is not None:
|
||||
try:
|
||||
overlay.place_forget()
|
||||
except tk.TclError:
|
||||
pass
|
||||
if changed:
|
||||
self._show_frame(self._last_video_frame, store=False)
|
||||
return
|
||||
if not changed:
|
||||
return
|
||||
try:
|
||||
self.subtitle_en.configure(text=english)
|
||||
if chinese:
|
||||
@@ -2290,6 +2734,45 @@ class MediaPlayerApp(tk.Tk):
|
||||
number += 1
|
||||
return "\n\n".join(blocks) + ("\n" if blocks else "")
|
||||
|
||||
def _build_ass_content(self, sentences, include_chinese: bool, title: str = "") -> str:
|
||||
events = []
|
||||
for sentence in sentences:
|
||||
english = str(sentence.get("text") or "").strip()
|
||||
chinese = str(sentence.get("zh_text") or "").strip() if include_chinese else ""
|
||||
if not english and not chinese:
|
||||
continue
|
||||
start = _ass_timestamp(sentence.get("start_ms") or 0)
|
||||
end = _ass_timestamp(sentence.get("end_ms") or 0)
|
||||
if english and chinese:
|
||||
text = "{{\\an2\\fnMicrosoft YaHei\\b1\\fs60}}{}\\N{{\\b0\\fs48}}{}".format(
|
||||
_escape_ass_text(english),
|
||||
_escape_ass_text(chinese),
|
||||
)
|
||||
elif english:
|
||||
text = "{{\\an2\\fnMicrosoft YaHei\\b1\\fs60}}{}".format(_escape_ass_text(english))
|
||||
else:
|
||||
text = "{{\\an2\\fnMicrosoft YaHei\\b0\\fs48}}{}".format(_escape_ass_text(chinese))
|
||||
events.append("Dialogue: 0,{},{},Default,,0,0,0,,{}".format(start, end, text))
|
||||
safe_title = (title or "Subtitles").replace("\r", " ").replace("\n", " ")
|
||||
header_lines = [
|
||||
"[Script Info]",
|
||||
"Title: " + safe_title,
|
||||
"ScriptType: v4.00+",
|
||||
"WrapStyle: 0",
|
||||
"ScaledBorderAndShadow: yes",
|
||||
"Collisions: Normal",
|
||||
"PlayResX: 1920",
|
||||
"PlayResY: 1080",
|
||||
"",
|
||||
"[V4+ Styles]",
|
||||
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
||||
"Style: Default,Microsoft YaHei,52,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,3,0,2,80,80,50,1",
|
||||
"",
|
||||
"[Events]",
|
||||
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
||||
]
|
||||
return "\r\n".join(header_lines + events) + "\r\n"
|
||||
|
||||
def _sync_vlc_subtitles(self, force=False):
|
||||
if not isinstance(self.engine, VlcEngine):
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user