395 lines
20 KiB
Python
395 lines
20 KiB
Python
# filename: srt_interactive_refiner_v2.0.py
|
||
|
||
import tkinter as tk
|
||
from tkinter import filedialog, ttk, messagebox, scrolledtext
|
||
import sv_ttk
|
||
import re
|
||
from datetime import timedelta
|
||
import os
|
||
import requests
|
||
import json
|
||
import threading
|
||
import queue
|
||
import copy
|
||
|
||
# --- 配置区 ---
|
||
OLLAMA_HOST = "http://127.0.0.1:11434"
|
||
|
||
# --- SRT核心类 和 解析函数 ---
|
||
class SrtEntry:
|
||
def __init__(self, index, start_td, end_td, text, original_index=None):
|
||
self.index = index
|
||
self.start_td = start_td
|
||
self.end_td = end_td
|
||
self.text = text.strip()
|
||
self.original_index = original_index if original_index is not None else index
|
||
|
||
@property
|
||
def start_str(self): return self._td_to_str(self.start_td)
|
||
@property
|
||
def end_str(self): return self._td_to_str(self.end_td)
|
||
|
||
@staticmethod
|
||
def _td_to_str(td):
|
||
total_seconds = int(td.total_seconds())
|
||
ms = int((td.total_seconds() - total_seconds) * 1000)
|
||
h, m, s = total_seconds // 3600, (total_seconds % 3600) // 60, total_seconds % 60
|
||
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
||
|
||
def to_srt_block(self):
|
||
return f"{self.index}\n{self.start_str} --> {self.end_str}\n{self.text}\n\n"
|
||
|
||
def parse_srt(content):
|
||
entries = []
|
||
pattern = re.compile(r'(\d+)\n(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\n([\s\S]*?)(?=\n\n|\Z)', re.MULTILINE)
|
||
def to_td(time_str):
|
||
h, m, s, ms = map(int, re.split('[:,]', time_str))
|
||
return timedelta(hours=h, minutes=m, seconds=s, milliseconds=ms)
|
||
for match in pattern.finditer(content):
|
||
index = int(match.group(1))
|
||
entries.append(SrtEntry(index, to_td(match.group(2)), to_td(match.group(3)), match.group(4)))
|
||
return entries
|
||
|
||
# --- GUI 应用 ---
|
||
class App:
|
||
def __init__(self, root):
|
||
self.root = root
|
||
self.root.title("交互式字幕编辑器 V2.0 (最终稳定版)")
|
||
self.root.geometry("1400x800")
|
||
|
||
self.srt_path = ""
|
||
self.original_entries = []
|
||
self.working_entries = []
|
||
self.current_selected_work_iid = None
|
||
self.current_selected_orig_iid = None
|
||
|
||
self.gui_queue = queue.Queue()
|
||
self.is_ollama_available = False
|
||
self.vars = { "ollama_model": tk.StringVar(), "status": tk.StringVar(value="准备就绪") }
|
||
|
||
self.build_ui()
|
||
sv_ttk.set_theme("dark")
|
||
|
||
self.root.after(100, self.process_queue)
|
||
self.root.after(100, self.load_ollama_models)
|
||
|
||
def build_ui(self):
|
||
main_pane = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
|
||
main_pane.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||
|
||
left_pane = ttk.Frame(main_pane)
|
||
main_pane.add(left_pane, weight=2)
|
||
|
||
control_frame = ttk.Frame(left_pane)
|
||
control_frame.pack(fill=tk.X, pady=(0, 10))
|
||
|
||
self.load_btn = ttk.Button(control_frame, text="加载SRT", command=self.load_srt)
|
||
self.load_btn.pack(side=tk.LEFT, padx=(0, 5))
|
||
self.save_btn = ttk.Button(control_frame, text="另存为...", command=self.save_srt, state="disabled")
|
||
self.save_btn.pack(side=tk.LEFT, padx=(0, 5))
|
||
self.refine_all_btn = ttk.Button(control_frame, text="🚀 一键润色", command=self.refine_all_lines, state="disabled", style="Accent.TButton")
|
||
self.refine_all_btn.pack(side=tk.LEFT, padx=(10,5))
|
||
ttk.Label(control_frame, text="Ollama:").pack(side=tk.LEFT, padx=(10, 5))
|
||
self.model_combo = ttk.Combobox(control_frame, textvariable=self.vars['ollama_model'], state="readonly", width=25)
|
||
self.model_combo.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||
|
||
orig_frame = ttk.Labelframe(left_pane, text="原始字幕 (对照区)")
|
||
orig_frame.pack(fill=tk.BOTH, expand=True)
|
||
self.tree_orig = self.create_treeview(orig_frame)
|
||
|
||
right_pane = ttk.Frame(main_pane)
|
||
main_pane.add(right_pane, weight=3)
|
||
|
||
work_frame = ttk.Labelframe(right_pane, text="工作区 (可编辑)")
|
||
work_frame.pack(fill=tk.BOTH, expand=True)
|
||
self.tree_work = self.create_treeview(work_frame)
|
||
self.tree_work.bind("<<TreeviewSelect>>", self.on_tree_select)
|
||
self.tree_work.tag_configure("modified", background="#3a6b3a")
|
||
|
||
editor_frame = ttk.Labelframe(right_pane, text="单句编辑器")
|
||
editor_frame.pack(fill=tk.X, pady=(10, 0))
|
||
|
||
self.editor_text = scrolledtext.ScrolledText(editor_frame, height=4, wrap=tk.WORD, state="disabled")
|
||
self.editor_text.pack(fill=tk.X, padx=5, pady=5)
|
||
|
||
button_bar = ttk.Frame(editor_frame)
|
||
button_bar.pack(fill=tk.X, padx=5, pady=(0, 5))
|
||
button_bar.columnconfigure((0, 1, 2, 3), weight=1)
|
||
|
||
self.refine_btn = ttk.Button(button_bar, text="润色", command=self.refine_current_line, state="disabled")
|
||
self.refine_btn.grid(row=0, column=0, sticky="ew", padx=(0, 2))
|
||
self.revert_btn = ttk.Button(button_bar, text="还原文本", command=self.revert_current_line_text, state="disabled")
|
||
self.revert_btn.grid(row=0, column=1, sticky="ew", padx=2)
|
||
|
||
self.apply_btn = ttk.Button(button_bar, text="✅ 应用更改", command=self.apply_editor_changes, state="disabled", style="Accent.TButton")
|
||
self.apply_btn.grid(row=1, column=0, columnspan=2, sticky="ew", padx=(0,2), pady=(5,0))
|
||
|
||
self.delete_btn = ttk.Button(button_bar, text="❌ 删除此行", command=self.delete_current_line, state="disabled")
|
||
self.delete_btn.grid(row=1, column=2, columnspan=2, sticky="ew", padx=2, pady=(5,0))
|
||
|
||
status_bar = ttk.Frame(self.root)
|
||
status_bar.pack(side=tk.BOTTOM, fill=tk.X, padx=10, pady=(0, 5))
|
||
self.progress_bar = ttk.Progressbar(status_bar, orient=tk.HORIZONTAL, mode='determinate')
|
||
self.progress_bar.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
|
||
ttk.Label(status_bar, textvariable=self.vars['status']).pack(side=tk.LEFT)
|
||
|
||
def create_treeview(self, parent):
|
||
cols = ("#0", "开始", "结束", "文本")
|
||
tree = ttk.Treeview(parent, columns=cols[1:], show="headings")
|
||
for col in cols: tree.heading(col, text=col, anchor="w")
|
||
tree.column("#0", width=40, anchor="center"); tree.column("开始", width=90, anchor="w")
|
||
tree.column("结束", width=90, anchor="w"); tree.column("文本", width=400, anchor="w")
|
||
vsb = ttk.Scrollbar(parent, orient="vertical", command=tree.yview); hsb = ttk.Scrollbar(parent, orient="horizontal", command=tree.xview)
|
||
tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
||
vsb.pack(side=tk.RIGHT, fill=tk.Y); hsb.pack(side=tk.BOTTOM, fill=tk.X); tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
return tree
|
||
|
||
def load_srt(self):
|
||
path = filedialog.askopenfilename(filetypes=[("SRT Subtitles", "*.srt")])
|
||
if not path: return
|
||
self.srt_path = path
|
||
try:
|
||
with open(path, 'r', encoding='utf-8-sig') as f: content = f.read()
|
||
self.original_entries = parse_srt(content)
|
||
self.working_entries = copy.deepcopy(self.original_entries)
|
||
self.populate_tree(self.tree_orig, self.original_entries)
|
||
self.repopulate_work_tree()
|
||
|
||
self.save_btn.config(state="normal")
|
||
self.refine_all_btn.config(state="normal" if self.is_ollama_available else "disabled")
|
||
|
||
self.current_selected_work_iid = None
|
||
self.editor_text.config(state="normal"); self.editor_text.delete(1.0, tk.END); self.editor_text.config(state="disabled")
|
||
self.set_editor_buttons_state(False)
|
||
|
||
self.vars['status'].set(f"已加载 {len(self.original_entries)} 条字幕。")
|
||
except Exception as e: messagebox.showerror("加载失败", f"无法加载或解析文件: {e}")
|
||
|
||
def populate_tree(self, tree, entries):
|
||
tree.delete(*tree.get_children())
|
||
for entry in entries:
|
||
values = (entry.start_str, entry.end_str, entry.text.replace('\n', ' '))
|
||
tree.insert("", "end", text=str(entry.original_index), values=values, iid=str(entry.original_index))
|
||
|
||
def repopulate_work_tree(self):
|
||
last_selected = self.current_selected_work_iid
|
||
self.tree_work.delete(*self.tree_work.get_children())
|
||
for i, entry in enumerate(self.working_entries):
|
||
entry.index = i + 1
|
||
values = (entry.start_str, entry.end_str, entry.text.replace('\n', ' '))
|
||
|
||
is_modified = entry.text != self.original_entries[entry.original_index - 1].text
|
||
tags = ("modified",) if is_modified else ()
|
||
self.tree_work.insert("", "end", text=str(entry.index), values=values, iid=str(entry.index), tags=tags)
|
||
|
||
if last_selected and self.tree_work.exists(str(last_selected)):
|
||
self.tree_work.selection_set(str(last_selected))
|
||
self.tree_work.see(str(last_selected))
|
||
|
||
|
||
def on_tree_select(self, event):
|
||
selection = self.tree_work.selection()
|
||
if not selection: return
|
||
|
||
work_iid = int(selection[0])
|
||
if work_iid > len(self.working_entries): return
|
||
self.current_selected_work_iid = work_iid
|
||
|
||
orig_iid = self.working_entries[work_iid - 1].original_index
|
||
self.current_selected_orig_iid = orig_iid
|
||
|
||
entry_text = self.working_entries[work_iid - 1].text
|
||
self.editor_text.config(state="normal")
|
||
self.editor_text.delete(1.0, tk.END)
|
||
self.editor_text.insert(tk.END, entry_text)
|
||
self.set_editor_buttons_state(True)
|
||
|
||
if self.tree_orig.exists(str(orig_iid)):
|
||
self.tree_orig.selection_set(str(orig_iid))
|
||
self.tree_orig.see(str(orig_iid))
|
||
|
||
def set_editor_buttons_state(self, is_enabled):
|
||
state = "normal" if is_enabled else "disabled"
|
||
is_ready = self.is_ollama_available and is_enabled
|
||
self.refine_btn.config(state="normal" if is_ready else "disabled")
|
||
self.revert_btn.config(state=state)
|
||
self.apply_btn.config(state=state)
|
||
self.delete_btn.config(state=state)
|
||
|
||
def save_srt(self):
|
||
if not self.working_entries: return
|
||
self.apply_editor_changes()
|
||
|
||
original_basename = os.path.splitext(os.path.basename(self.srt_path))[0]
|
||
save_path = filedialog.asksaveasfilename(defaultextension=".srt", initialfile=f"{original_basename}_edited.srt", filetypes=[("SRT Subtitles", "*.srt")])
|
||
if not save_path: return
|
||
try:
|
||
for i, entry in enumerate(self.working_entries):
|
||
entry.index = i + 1
|
||
with open(save_path, 'w', encoding='utf-8') as f:
|
||
for entry in self.working_entries: f.write(entry.to_srt_block())
|
||
messagebox.showinfo("保存成功", f"文件已保存至:\n{save_path}")
|
||
except Exception as e: messagebox.showerror("保存失败", f"无法保存文件: {e}")
|
||
|
||
def apply_editor_changes(self):
|
||
if self.current_selected_work_iid is not None:
|
||
entry_index_in_list = self.current_selected_work_iid - 1
|
||
if entry_index_in_list < len(self.working_entries):
|
||
new_text = self.editor_text.get(1.0, tk.END).strip()
|
||
if new_text != self.working_entries[entry_index_in_list].text:
|
||
self.working_entries[entry_index_in_list].text = new_text
|
||
self.repopulate_work_tree()
|
||
self.vars['status'].set(f"第 {self.current_selected_work_iid} 行已更新。")
|
||
|
||
def delete_current_line(self):
|
||
if self.current_selected_work_iid is None: return
|
||
entry_index_in_list = self.current_selected_work_iid - 1
|
||
entry_to_delete = self.working_entries[entry_index_in_list]
|
||
|
||
if messagebox.askyesno("确认删除", f"确定要删除第 {entry_to_delete.index} 行吗?\n'{entry_to_delete.text[:50]}...'"):
|
||
self.working_entries.pop(entry_index_in_list)
|
||
self.repopulate_work_tree()
|
||
self.current_selected_work_iid = None
|
||
self.editor_text.config(state="normal"); self.editor_text.delete(1.0, tk.END); self.editor_text.config(state="disabled")
|
||
self.set_editor_buttons_state(False)
|
||
self.vars['status'].set(f"原始行 {entry_to_delete.original_index} 已删除。")
|
||
|
||
def refine_current_line(self):
|
||
if self.current_selected_work_iid is None: return
|
||
self.apply_editor_changes()
|
||
original_text = self.original_entries[self.current_selected_orig_iid - 1].text
|
||
self.set_buttons_state(False)
|
||
threading.Thread(target=self._call_llm_for_refine, args=(original_text, self.vars['ollama_model'].get(), self.current_selected_work_iid), daemon=True).start()
|
||
|
||
def refine_all_lines(self):
|
||
if not self.working_entries: return
|
||
if messagebox.askyesno("确认", f"即将对全部字幕进行润色,这会覆盖您当前的修改。是否继续?"):
|
||
self.set_buttons_state(False)
|
||
self.progress_bar['value'] = 0; self.progress_bar['maximum'] = len(self.original_entries)
|
||
threading.Thread(target=self._refine_all_worker, args=(self.vars['ollama_model'].get(),), daemon=True).start()
|
||
|
||
def _refine_all_worker(self, model_name):
|
||
for i, entry in enumerate(self.original_entries):
|
||
self.vars['status'].set(f"正在处理 {i+1}/{len(self.original_entries)}...")
|
||
self.progress_bar['value'] = i + 1
|
||
refined_text = self._call_llm_for_refine_sync(entry.text, model_name)
|
||
|
||
if refined_text:
|
||
self.gui_queue.put({"type": "batch_line_refined", "data": {"orig_iid": entry.index, "text": refined_text}})
|
||
else:
|
||
self.gui_queue.put({"type": "batch_line_refined", "data": {"orig_iid": entry.index, "text": entry.text, "no_change": True}})
|
||
self.gui_queue.put({"type": "batch_finish"})
|
||
|
||
def revert_current_line_text(self):
|
||
if self.current_selected_orig_iid is None: return
|
||
original_text = self.original_entries[self.current_selected_orig_iid - 1].text
|
||
self.editor_text.delete(1.0, tk.END); self.editor_text.insert(tk.END, original_text)
|
||
|
||
def _call_llm_for_refine(self, text, model, work_iid):
|
||
refined_text = self._call_llm_for_refine_sync(text, model)
|
||
if refined_text: self.gui_queue.put({"type": "line_refined", "data": {"iid": work_iid, "text": refined_text}})
|
||
else: self.gui_queue.put({"type": "refine_failed", "data": work_iid})
|
||
|
||
def _call_llm_for_refine_sync(self, text, model):
|
||
prompt = f"""你是一个专业的视频字幕精炼师。任务是优化“待处理字幕”,使其更适合专业配音。
|
||
规则:
|
||
1. 改为流畅、专业的书面语,但必须保留所有的核心操作指令和细节。
|
||
2. 优先去除明显的口语化词汇、重复和不必要的填充词。
|
||
3. 在不影响信息完整性的前提下,可以适当缩短句子。
|
||
4. 【重要】只输出精炼后的字幕文本,不要包含任何标签、解释或引号。
|
||
---
|
||
[待处理字幕]
|
||
{text}
|
||
---
|
||
[精炼后的文本]:"""
|
||
payload = {"model": model, "prompt": prompt, "stream": False, "options": {'temperature': 0.3}}
|
||
try:
|
||
response = requests.post(f"{OLLAMA_HOST}/api/generate", json=payload, timeout=45)
|
||
response.raise_for_status()
|
||
response_data = response.json()
|
||
refined_text = response_data.get('response', '').strip().replace("\n", " ")
|
||
return re.sub(r'^["\'“‘]|["\'”’]$', '', refined_text)
|
||
except Exception as e:
|
||
self.gui_queue.put({"type": "error", "data": f"API调用失败: {e}"})
|
||
return None
|
||
|
||
def process_queue(self):
|
||
try:
|
||
while True:
|
||
msg = self.gui_queue.get_nowait()
|
||
msg_type, data = msg.get("type"), msg.get("data")
|
||
if msg_type == "line_refined":
|
||
if data["iid"] == self.current_selected_work_iid:
|
||
self.editor_text.delete(1.0, tk.END)
|
||
self.editor_text.insert(tk.END, data["text"])
|
||
self.set_buttons_state(True)
|
||
elif msg_type == "batch_line_refined":
|
||
orig_iid = data["orig_iid"]
|
||
for work_entry in self.working_entries:
|
||
if work_entry.original_index == orig_iid:
|
||
work_entry.text = data["text"]
|
||
values = (work_entry.start_str, work_entry.end_str, work_entry.text.replace('\n', ' '))
|
||
self.tree_work.item(str(work_entry.index), values=values, tags=("modified",))
|
||
break
|
||
elif msg_type == "batch_finish":
|
||
self.repopulate_work_tree()
|
||
self.vars['status'].set("批量润色完成!")
|
||
self.set_buttons_state(True)
|
||
messagebox.showinfo("完成", "所有字幕已批量润色。请检查并进行微调。")
|
||
elif msg_type == "refine_failed":
|
||
if data == self.current_selected_work_iid: self.set_buttons_state(True)
|
||
elif msg_type == "error": messagebox.showerror("Ollama 错误", data)
|
||
elif msg_type == "models_loaded":
|
||
if data:
|
||
self.model_combo['values'] = data; self.model_combo.set(data[0])
|
||
self.is_ollama_available = True
|
||
if self.working_entries: self.refine_all_btn.config(state="normal")
|
||
else:
|
||
self.gui_queue.put({"type": "error", "data": "Ollama连接成功, 但未检测到任何模型。请确保您已下载模型。"})
|
||
|
||
except queue.Empty: pass
|
||
finally: self.root.after(100, self.process_queue)
|
||
|
||
def set_buttons_state(self, is_enabled):
|
||
state = "normal" if is_enabled else "disabled"
|
||
self.load_btn.config(state=state); self.save_btn.config(state=state); self.refine_all_btn.config(state=state)
|
||
self.set_editor_buttons_state(is_enabled and self.current_selected_work_iid is not None)
|
||
|
||
def load_ollama_models(self):
|
||
threading.Thread(target=self._load_models_worker, daemon=True).start()
|
||
|
||
def _load_models_worker(self):
|
||
# *** 修复: 补全此函数并增强错误处理 ***
|
||
try:
|
||
self.vars['status'].set("正在连接Ollama...")
|
||
response = requests.get(f"{OLLAMA_HOST}/api/tags", timeout=5)
|
||
response.raise_for_status() # 如果状态码不是 200-299,则抛出异常
|
||
|
||
# 检查返回的是否是有效的JSON
|
||
try:
|
||
models_data = response.json()
|
||
except json.JSONDecodeError:
|
||
self.gui_queue.put({"type": "error", "data": "Ollama返回了无效的数据格式,无法解析模型列表。"})
|
||
return
|
||
|
||
models = models_data.get('models')
|
||
if models is not None:
|
||
model_names = [m['name'] for m in models]
|
||
self.gui_queue.put({"type": "models_loaded", "data": model_names})
|
||
self.vars['status'].set("Ollama连接成功!")
|
||
else: # models 键不存在
|
||
self.gui_queue.put({"type": "models_loaded", "data": []})
|
||
|
||
except requests.exceptions.Timeout:
|
||
self.gui_queue.put({"type": "error", "data": f"连接Ollama超时 ({OLLAMA_HOST})。\n请检查服务是否运行且地址正确。"})
|
||
except requests.exceptions.ConnectionError:
|
||
self.gui_queue.put({"type": "error", "data": f"无法连接到Ollama ({OLLAMA_HOST})。\n请确保Ollama服务正在运行。"})
|
||
except requests.exceptions.RequestException as e:
|
||
self.gui_queue.put({"type": "error", "data": f"连接Ollama时发生网络错误: {e}"})
|
||
finally:
|
||
if not self.is_ollama_available: self.vars['status'].set("Ollama连接失败,润色功能不可用。")
|
||
|
||
if __name__ == "__main__":
|
||
root = tk.Tk()
|
||
app = App(root)
|
||
root.mainloop() |