From 9d0ed5820de681786e119307f5c1ea23d180e11d Mon Sep 17 00:00:00 2001 From: hmo Date: Mon, 4 May 2026 01:06:59 +0800 Subject: [PATCH] feat(gui): implement GUI edit mode with atomic clip operations Phase 2 complete - GUI editing layer on top of Phase 1 CLI: Config: - config.py now only has API keys (API_KEY, API_HOST, PYTHON, CLI_DIR) - Project paths (video_src, ppt_path, max_total_duration) in generated_config.yaml Atomic clip operations (Pipeline methods): - reextract_clip(clip_index, new_title) - re-match title in transcript - delete_clip(clip_index) - remove clip and its intermediate files - add_clip_by_title(new_title) - match new title, handle overlaps - reburn_titles() - re-burn title track from updated clips - reburn_subtitles(user_texts) - burn user-edited subtitles directly - _find_title_in_transcript() - substring match in corrected transcript GUI: - Two startup modes: new project / open existing project - Clip list with double-click rename, right-click delete, + add - Subtitle preview with direct text editing - Apply button orchestrates reburn_titles/reburn_subtitles - Unmatched clips shown with warning label, excluded from burn CLI/GUI interoperability: - Both use same generated_config.yaml as single source of truth - CLI: run.bat for full pipeline, burn.bat for quick reburn - GUI: open project dir, edit, apply Docs: - USAGE.md updated with GUI edit mode documentation --- burn_only.py | 41 +++-- docs/USAGE.md | 68 ++++++++ src/core/pipeline.py | 273 ++++++++++++++++++++++++++++++ src/core/ppt_parser.py | 24 +++ src/gui.py | 366 +++++++++++++++++++++++++++++++++++++++-- 5 files changed, 742 insertions(+), 30 deletions(-) diff --git a/burn_only.py b/burn_only.py index 8359ffd..da07aeb 100644 --- a/burn_only.py +++ b/burn_only.py @@ -4,19 +4,18 @@ 直接用已有的 clips + title.srt + content.srt 合并烧录 用法: - python burn_only.py python burn_only.py "D:\\path\\to\\output_dir" """ import sys import os -# 导入统一配置 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import config +# 必须指定输出目录 +if len(sys.argv) < 2: + print("用法: python burn_only.py ") + print("示例: python burn_only.py \"D:\\path\\to\\output_dir\"") + sys.exit(1) -OUTPUT = config.OUTPUT -if len(sys.argv) > 1: - OUTPUT = sys.argv[1] +OUTPUT = sys.argv[1] TITLE_SRT = os.path.join(OUTPUT, "subs", "v1_title.srt") CONTENT_SRT = os.path.join(OUTPUT, "subs", "v1_content.srt") @@ -40,16 +39,24 @@ src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src") sys.path.insert(0, src_dir) from core import Pipeline -# 构造 minimal config(只需要 output_dir 和 video_params) -pipeline_config = { - 'output_dir': OUTPUT, - 'clips': [], - 'video_src': None, - 'video_params': {}, - 'term_corrections': {}, - 'api_key': '', - 'api_host': '', -} +# 尝试从 generated_config.yaml 加载配置 +config_path = os.path.join(OUTPUT, 'generated_config.yaml') +if os.path.exists(config_path): + import yaml + with open(config_path, 'r', encoding='utf-8') as f: + pipeline_config = yaml.safe_load(f) or {} + pipeline_config['output_dir'] = OUTPUT +else: + # 构造 minimal config + pipeline_config = { + 'output_dir': OUTPUT, + 'clips': [], + 'video_src': None, + 'video_params': {}, + 'term_corrections': {}, + 'api_key': '', + 'api_host': '', + } pipeline = Pipeline(pipeline_config) diff --git a/docs/USAGE.md b/docs/USAGE.md index 86d1acd..cd9033b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -115,3 +115,71 @@ A: 直接改 `v1_title.srt` 里的时间轴,或者改 `generated_config.yaml` **Q: 想删掉某个 clip?** A: 从 `generated_config.yaml` 里删掉那一条,然后删对应 `intermediates/clip*.json` 和 `clip*.mp4`,最后 `run.bat`。 + +## GUI 编辑模式 + +### 启动方式 + +GUI 支持两种启动模式: + +**方式一:新建项目** +```bash +python src/main.py +# 或双击 start.bat +``` +在启动页选择"新建项目",选择视频+PPT,运行完整流程。 + +**方式二:打开已有项目** +在启动页选择"打开已有项目",选择 output 目录。 +项目文件 `generated_config.yaml` 包含所有项目信息(视频路径、PPT路径、知识点列表)。 + +### 编辑知识点 + +打开项目后进入编辑界面: + +- **改标题**:双击 clip 列表中的任意条目,输入新标题 → 系统自动在 transcript 中重新匹配时间段 → 处理重叠 +- **删 clip**:右键点击 clip → 删除此 Clip +- **新增知识点**:点击"+ 新增知识点"按钮,输入标题 → 系统在 transcript 中匹配 → 如有重叠自动合并 + +> 注意:匹配不到的 clip 显示红色背景的"未匹配"标签,不参与最终烧录 + +### 编辑字幕 + +右侧字幕预览区显示 `v1_content.srt` 内容,可直接编辑文本。 + +### 应用更改 + +点击"应用"按钮: +1. 检测字幕是否有修改 → 有则重烧字幕轨 +2. 检测 clips 是否有修改 → 重烧标题轨 +3. 自动合并+烧录最终视频 + +### 文件结构 + +``` +output_dir/ +├── generated_config.yaml ← 项目文件(GUI 编辑后同步更新) +├── intermediates/ ← 中间缓存 +│ ├── clip*.json ← Whisper 转录(删后可重新生成) +│ ├── clip*.mp4 ← 视频片段 +│ └── corrected_transcript.json ← LLM校正后的全量转录 +├── subs/ +│ ├── v1_title.srt ← 标题轨 +│ └── v1_content.srt ← 字幕轨(可直接编辑) +└── final.mp4 ← 最终输出 +``` + +## CLI/GUI 互操作 + +CLI 和 GUI 共用同一套项目文件体系: + +```bash +# 1. CLI 批量处理 +run.bat + +# 2. GUI 审核修改 +python src/main.py +# → 选择"打开已有项目" → 选择同一 output_dir +``` + +修改后点"应用",GUI 自动判断需要重生成哪些部分。 diff --git a/src/core/pipeline.py b/src/core/pipeline.py index 9115ab5..1549a98 100644 --- a/src/core/pipeline.py +++ b/src/core/pipeline.py @@ -482,6 +482,279 @@ class Pipeline: self.step_callback('burning') return final_path + # ==================== 辅助方法 ==================== + + def _save_config(self) -> None: + """将 self.clips 等配置写回 generated_config.yaml。""" + import yaml + config_path = os.path.join(self.output_dir, 'generated_config.yaml') + # 保留原有配置,只更新 clips 和 video_params + saved_config = {} + if os.path.exists(config_path): + try: + with open(config_path, 'r', encoding='utf-8') as f: + saved_config = yaml.safe_load(f) or {} + except Exception: + pass + saved_config['clips'] = self.clips + # 同步 video_params + if 'video_params' not in saved_config: + saved_config['video_params'] = self.config.get('video_params', {}) + with open(config_path, 'w', encoding='utf-8') as f: + yaml.dump(saved_config, f, allow_unicode=True, default_flow_style=False) + logger.info(f"配置已保存: {config_path}") + + def reextract_clip(self, clip_index: int, new_title: str) -> bool: + """ + 用新标题重新匹配单个 clip 的时间段。 + + Args: + clip_index: clip 在 self.clips 中的索引 + new_title: 新的标题文字 + + Returns: + bool: 是否匹配成功(匹配到了返回 True,匹配不到返回 False) + """ + # 1. 加载 corrected_transcript.json + transcript_path = os.path.join(self.inter_dir, 'corrected_transcript.json') + if not os.path.exists(transcript_path): + logger.warning(f"corrected_transcript.json not found: {transcript_path}") + return False + + with open(transcript_path, 'r', encoding='utf-8') as f: + corrected_segments = json.load(f) + + # 2. 调用 PPTParser._find_title_in_transcript 匹配新标题 + from .ppt_parser import PPTParser + # 用 __new__ 绕过 __init__,只设置 inter_dir + parser = PPTParser.__new__(PPTParser) + parser.inter_dir = self.inter_dir + result = parser._find_title_in_transcript(new_title, corrected_segments) + + if result is None: + # 匹配不到:标记为 unmatched,不参与烧录 + self.clips[clip_index]['matched'] = False + self.clips[clip_index]['title'] = new_title + clip_json = os.path.join(self.inter_dir, f'clip{clip_index + 1}.json') + if os.path.exists(clip_json): + os.remove(clip_json) + self._save_config() + return False + + start, end = result + self.clips[clip_index]['title'] = new_title + self.clips[clip_index]['start'] = start + self.clips[clip_index]['end'] = end + self.clips[clip_index]['matched'] = True + + # 3. 删除对应 json(触发重新生成) + clip_json = os.path.join(self.inter_dir, f'clip{clip_index + 1}.json') + if os.path.exists(clip_json): + os.remove(clip_json) + + # 4. 重新合并重叠片段 + self.clips = parser._merge_overlapping_clips(self.clips) + + # 5. 保存更新后的 config + self._save_config() + return True + + def delete_clip(self, clip_index): + """ + 删除指定 clip。 + + Args: + clip_index: clip 在 self.clips 中的索引 + """ + if clip_index < 0 or clip_index >= len(self.clips): + return + + # 从 self.clips 删除 + self.clips.pop(clip_index) + + # 删除对应的中间文件(文件名是 clip{index+1} 因为是 1-based) + clip_num = clip_index + 1 + for ext in ['.json', '.mp4', '_fade.mp4']: + path = os.path.join(self.inter_dir, f'clip{clip_num}{ext}') + if os.path.exists(path): + os.remove(path) + + # 不重编号后续 clip 文件,GUI 加载时按顺序读取所有 clip*.* 文件即可 + + # 保存更新后的 config + self._save_config() + + def add_clip_by_title(self, new_title): + """ + 用新标题在转录中匹配时间段,判断合并或新增。 + + Args: + new_title: 新知识点标题 + + Returns: + tuple: (clip_index, matched) — 新 clip 在 self.clips 中的索引,matched 是否匹配成功 + """ + # 1. 加载 corrected_transcript.json + transcript_path = os.path.join(self.inter_dir, 'corrected_transcript.json') + if not os.path.exists(transcript_path): + logger.warning(f"corrected_transcript.json not found") + return None, False + + with open(transcript_path, 'r', encoding='utf-8') as f: + corrected_segments = json.load(f) + + # 2. 匹配标题 + from .ppt_parser import PPTParser + parser = PPTParser.__new__(PPTParser) + parser.inter_dir = self.inter_dir + result = parser._find_title_in_transcript(new_title, corrected_segments) + + if result is None: + # 匹配不到:不加入 clips(用户可在 GUI 中看到未匹配状态) + logger.info(f"标题 '{new_title}' 在转录中未找到匹配") + return None, False + + start, end = result + + # 3. 构建新 clip + new_clip = { + 'title': new_title, + 'start': start, + 'end': end, + 'matched': True, + } + + # 4. 判断是否与现有 clip 重叠 + overlapped_index = None + for i, clip in enumerate(self.clips): + if clip.get('matched', True) is False: + continue + if start < clip['end'] and end > clip['start']: + overlapped_index = i + break + + if overlapped_index is not None: + # 有重叠:合并到现有 clip + self.clips.append(new_clip) + self.clips = parser._merge_overlapping_clips(self.clips) + else: + # 无重叠:直接追加 + self.clips.append(new_clip) + # 重新排序 + self.clips = sorted(self.clips, key=lambda c: c['start']) + + # 找到新 clip 的索引 + new_clip_index = next( + i for i, c in enumerate(self.clips) + if c['title'] == new_title and c['start'] == start + ) + + # 5. 保存 config + self._save_config() + return new_clip_index, True + + def reburn_titles(self) -> str: + """ + 只重烧标题轨。 + + 用已有 intermediates/clip*.json 重新生成标题轨并烧录, + 保留用户修改过的字幕内容。 + + Returns: + final_path: 最终视频路径 + """ + import glob + import shutil + + # 确保有合并视频 + merged_path = os.path.join(self.output_dir, 'concat_merged.mp4') + if not os.path.exists(merged_path): + clip_files = sorted(glob.glob(os.path.join(self.inter_dir, 'clip*.mp4'))) + if not clip_files: + raise ValueError("No clips found") + merged_path = self.step_merge(clip_files) + + # 收集 json paths + json_paths = [ + os.path.join(self.inter_dir, f'clip{i+1}.json') + for i in range(len(self.clips)) + ] + json_paths = [p for p in json_paths if os.path.exists(p)] + if not json_paths: + raise ValueError("No clip JSON files found") + + # 备份用户字幕(如果存在) + content_path = os.path.join(self.subs_dir, 'v1_content.srt') + content_backup = content_path + '.reburn_titles.bak' + if os.path.exists(content_path): + shutil.copy(content_path, content_backup) + + # 生成字幕(标题+内容) + title_path, _ = self.step_generate_subtitles(self.clips, json_paths) + + # 恢复用户字幕 + if os.path.exists(content_backup): + shutil.move(content_backup, content_path) + + # 烧录 + final_path = self.step_burn(merged_path, title_path, content_path) + return final_path + + def reburn_subtitles(self, user_texts=None) -> str: + """ + 只重烧字幕轨。 + + 跳过 LLM 校正步骤,直接从 json 生成或使用用户提供的文本。 + + Args: + user_texts: 可选,用户直接提供的 SRT 格式字幕文本。 + 如果为 None,从 json 重新生成(跳过 LLM 校正)。 + + Returns: + final_path: 最终视频路径 + """ + import glob + + # 确保有合并视频 + merged_path = os.path.join(self.output_dir, 'concat_merged.mp4') + if not os.path.exists(merged_path): + clip_files = sorted(glob.glob(os.path.join(self.inter_dir, 'clip*.mp4'))) + if not clip_files: + raise ValueError("No clips found") + merged_path = self.step_merge(clip_files) + + # 获取标题轨(用已有的,不重新生成标题) + title_path = os.path.join(self.subs_dir, 'v1_title.srt') + if not os.path.exists(title_path): + json_paths = [ + os.path.join(self.inter_dir, f'clip{i+1}.json') + for i in range(len(self.clips)) + ] + json_paths = [p for p in json_paths if os.path.exists(p)] + if json_paths: + title_path, _ = self.step_generate_subtitles(self.clips, json_paths) + + # 处理字幕内容 + if user_texts is not None: + # 用户直接提供字幕文本,写入文件 + content_path = os.path.join(self.subs_dir, 'v1_content.srt') + with open(content_path, 'w', encoding='utf-8') as f: + f.write(user_texts) + else: + # 从 json 重新生成字幕(跳过 LLM 校正) + json_paths = [ + os.path.join(self.inter_dir, f'clip{i+1}.json') + for i in range(len(self.clips)) + ] + json_paths = [p for p in json_paths if os.path.exists(p)] + if not json_paths: + raise ValueError("No clip JSON files found") + _, content_path = self.step_generate_subtitles(self.clips, json_paths) + + # 烧录 + final_path = self.step_burn(merged_path, title_path, content_path) + return final_path + # ==================== 主流程 ==================== def run(self) -> str: diff --git a/src/core/ppt_parser.py b/src/core/ppt_parser.py index 0a927d3..5ea03e1 100644 --- a/src/core/ppt_parser.py +++ b/src/core/ppt_parser.py @@ -963,6 +963,30 @@ class PPTParser: logger.warning(f"LLM提取片段失败: {e}") return None + def _find_title_in_transcript(self, title: str, corrected_segments: List[Dict]) -> Optional[Tuple[float, float]]: + """ + 在转录文本中搜索标题关键词,返回首次匹配的时间段。 + + 匹配逻辑:子串匹配(transcript text 包含 title 即为匹配) + + Args: + title: 知识点标题 + corrected_segments: corrected_transcript.json 的 segments 列表 + 每个元素格式: {start, end, text} + + Returns: + (start, end) 时间戳元组,或 None(匹配不到) + """ + if not title or not corrected_segments: + return None + + # 搜索策略:在所有 segment text 中找包含 title 的 segment + for seg in corrected_segments: + text = seg.get('text', '') + if title in text: + return (seg['start'], seg['end']) + return None + # ==================== 主流程 ==================== def run(self) -> dict: diff --git a/src/gui.py b/src/gui.py index c3778e6..2dbaec6 100644 --- a/src/gui.py +++ b/src/gui.py @@ -10,6 +10,7 @@ import os import threading import logging import configparser +import yaml from pathlib import Path # 设置环境 @@ -23,9 +24,11 @@ if ffmpeg_path: from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, QTextEdit, QLabel, QFileDialog, - QMessageBox, QGroupBox, QFormLayout, QProgressBar + QMessageBox, QGroupBox, QFormLayout, QProgressBar, QListWidget, + QListWidgetItem, QInputDialog, QMenu ) from PySide6.QtCore import Qt, Signal, QObject +from PySide6.QtGui import QColor # 底层函数(与 CLI 共用) from core import parse_ppt_to_config, Pipeline @@ -63,16 +66,354 @@ class GUI(QMainWindow): self.signaller = Signaller() self.worker_thread = None - self._setup_ui() + self.pipeline = None # 当前 Pipeline 实例 + self.project_config = None # 当前项目配置 # 连接信号 self.signaller.log_signal.connect(self._append_log) self.signaller.finished_signal.connect(self._on_finished) - def _setup_ui(self): + # 显示启动页 + self._setup_start_page() + + def _clear_ui(self): + """清空当前 UI""" + central = self.centralWidget() + if central: + central.deleteLater() + + def _setup_start_page(self): + """启动页:新建项目 / 打开已有项目""" self.setWindowTitle("Piano Highlight Generator - GUI") + self.setGeometry(100, 100, 500, 300) + + self._clear_ui() + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + layout.setAlignment(Qt.AlignCenter) + + # 标题 + title = QLabel("Piano Highlight Generator") + title.setStyleSheet("font-size: 24px; font-weight: bold;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + layout.addSpacing(40) + + # 按钮 + self.new_project_btn = QPushButton("新建项目") + self.new_project_btn.setMinimumHeight(50) + self.new_project_btn.clicked.connect(self._on_new_project) + layout.addWidget(self.new_project_btn) + + self.open_project_btn = QPushButton("打开已有项目") + self.open_project_btn.setMinimumHeight(50) + self.open_project_btn.clicked.connect(self._on_open_project) + layout.addWidget(self.open_project_btn) + + layout.addStretch() + + def _on_new_project(self): + """新建项目:切换到文件选择界面""" + self._setup_new_project_ui() + + def _on_open_project(self): + """打开已有项目""" + dir_path = QFileDialog.getExistingDirectory(self, "选择项目目录") + if not dir_path: + return + self.load_project(dir_path) + + def load_project(self, output_dir): + """ + 加载已有项目,加载 generated_config.yaml,显示 clip 列表 + """ + config_path = os.path.join(output_dir, 'generated_config.yaml') + if not os.path.exists(config_path): + QMessageBox.critical(self, "错误", f"不是有效的项目目录:\n{config_path}") + return + + with open(config_path, 'r', encoding='utf-8') as f: + self.project_config = yaml.safe_load(f) + + self.project_config['output_dir'] = output_dir + + # 初始化 Pipeline + self.pipeline = Pipeline(self.project_config) + + # 显示项目信息(Task 9 完成后替换为完整编辑界面) + self._show_project_info() + + def _show_project_info(self): + """显示项目基本信息(临时,过渡用)""" + self.setWindowTitle("项目信息 - Piano Highlight Generator") + self.setGeometry(100, 100, 600, 400) + + self._clear_ui() + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + + # 项目路径 + layout.addWidget(QLabel(f"项目目录: {self.project_config.get('output_dir', 'N/A')}")) + + # 视频源 + video_src = self.project_config.get('video_src', 'N/A') + layout.addWidget(QLabel(f"视频: {video_src}")) + + # PPT 源 + ppt_src = self.project_config.get('ppt_src', 'N/A') + layout.addWidget(QLabel(f"PPT: {ppt_src}")) + + # Clips 数量 + clips = self.project_config.get('clips', []) + layout.addWidget(QLabel(f"Clips 数量: {len(clips)}")) + + # Clips 列表 + if clips: + list_label = QLabel("Clips 列表:") + layout.addWidget(list_label) + for i, clip in enumerate(clips[:10]): # 只显示前10个 + title = clip.get('title', clip.get('content', 'N/A'))[:50] + start = clip.get('start_time', 'N/A') + end = clip.get('end_time', 'N/A') + layout.addWidget(QLabel(f" {i+1}. [{start}-{end}] {title}")) + if len(clips) > 10: + layout.addWidget(QLabel(f" ... 还有 {len(clips) - 10} 个")) + + layout.addStretch() + + # 按钮区 + btn_layout = QHBoxLayout() + self.edit_btn = QPushButton("进入编辑模式") + self.edit_btn.clicked.connect(self._setup_edit_ui) # Task 9 实现 + back_btn = QPushButton("返回") + back_btn.clicked.connect(self._setup_start_page) + btn_layout.addWidget(self.edit_btn) + btn_layout.addWidget(back_btn) + layout.addLayout(btn_layout) + + def _setup_edit_ui(self): + """编辑界面:左侧 clip 列表 + 右侧字幕预览""" + central = QWidget() + self.setCentralWidget(central) + main_layout = QVBoxLayout(central) # Changed to QVBoxLayout for bottom bar + + # Top: Split left and right + top_layout = QHBoxLayout() + + # === 左侧:clip 列表 === + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + + left_header = QLabel("知识点") + left_header.setStyleSheet("font-weight: bold; font-size: 14px;") + left_layout.addWidget(left_header) + + # clip 列表 + self.clip_list = QListWidget() + self.clip_list.itemDoubleClicked.connect(self._on_clip_double_clicked) + self.clip_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.clip_list.customContextMenuRequested.connect(self._on_clip_context_menu) + left_layout.addWidget(self.clip_list) + + # 底部按钮 + left_btn_layout = QHBoxLayout() + self.add_clip_btn = QPushButton("+ 新增知识点") + self.add_clip_btn.clicked.connect(self._on_add_clip) + self.add_clip_btn.setMaximumWidth(120) + left_btn_layout.addWidget(self.add_clip_btn) + left_btn_layout.addStretch() + left_layout.addLayout(left_btn_layout) + + top_layout.addWidget(left_widget, 1) + + # === 右侧:字幕预览 === + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + + right_header = QLabel("字幕预览(可直接编辑)") + right_header.setStyleSheet("font-weight: bold; font-size: 14px;") + right_layout.addWidget(right_header) + + self.subtitle_edit = QTextEdit() + self.subtitle_edit.setReadOnly(False) # 用户可编辑 + right_layout.addWidget(self.subtitle_edit) + + right_btn_layout = QHBoxLayout() + self.load_subtitle_btn = QPushButton("加载字幕") + self.load_subtitle_btn.clicked.connect(self._load_subtitle_file) + right_btn_layout.addWidget(self.load_subtitle_btn) + right_layout.addLayout(right_btn_layout) + + top_layout.addWidget(right_widget, 2) + main_layout.addLayout(top_layout, 1) # Stretch factor 1 for top area + + # === 底部状态栏 === + bottom_layout = QHBoxLayout() + self.status_label = QLabel("就绪") + bottom_layout.addWidget(self.status_label) + bottom_layout.addStretch() + + self.apply_btn = QPushButton("应用") + self.apply_btn.setStyleSheet("font-weight: bold; background-color: #4CAF50; color: white;") + self.apply_btn.clicked.connect(self._on_apply) # Task 10 实现 + bottom_layout.addWidget(self.apply_btn) + + self.back_btn = QPushButton("返回") + self.back_btn.clicked.connect(self._show_project_info) + bottom_layout.addWidget(self.back_btn) + + main_layout.addLayout(bottom_layout) + + # 填充 clip 列表 + self._refresh_clip_list() + + # 加载字幕 + self._load_subtitle_file() + + def _refresh_clip_list(self): + """刷新 clip 列表""" + self.clip_list.clear() + for i, clip in enumerate(self.pipeline.clips): + title = clip.get('title', '未知') + matched = clip.get('matched', True) + + item_text = f"Clip {i+1}: {title}" + if not matched: + item_text += " ⚠️ 未匹配" + + item = QListWidgetItem(item_text) + if not matched: + item.setBackground(QColor(255, 200, 200)) # 红色背景 + item.setData(Qt.ItemDataRole.UserRole, i) # 存索引 + self.clip_list.addItem(item) + + def _on_clip_double_clicked(self, item): + """双击 clip 进入编辑模式""" + clip_index = item.data(Qt.ItemDataRole.UserRole) + # 简单的实现:弹出 QInputDialog 让用户输入新标题 + clip = self.pipeline.clips[clip_index] + new_title, ok = QInputDialog.getText( + self, "修改标题", f"Clip {clip_index+1} 标题:", + text=clip.get('title', '') + ) + if ok and new_title.strip(): + self.pipeline.reextract_clip(clip_index, new_title.strip()) + self._refresh_clip_list() + self.status_label.setText(f"已修改 Clip {clip_index+1} 标题为: {new_title}") + + def _on_clip_context_menu(self, pos): + """右键菜单:删除""" + item = self.clip_list.itemAt(pos) + if not item: + return + clip_index = item.data(Qt.ItemDataRole.UserRole) + + menu = QMenu() + delete_action = menu.addAction("删除此 Clip") + action = menu.exec_(self.clip_list.mapToGlobal(pos)) + if action == delete_action: + self.pipeline.delete_clip(clip_index) + self._refresh_clip_list() + self.status_label.setText(f"已删除 Clip {clip_index+1}") + + def _on_add_clip(self): + """新增知识点""" + new_title, ok = QInputDialog.getText(self, "新增知识点", "请输入知识点标题:") + if ok and new_title.strip(): + idx, matched = self.pipeline.add_clip_by_title(new_title.strip()) + self._refresh_clip_list() + if matched: + self.status_label.setText(f"已新增: {new_title} (Clip {idx+1})") + else: + self.status_label.setText(f"已新增: {new_title},但未匹配到转录内容") + + def _load_subtitle_file(self): + """加载 v1_content.srt 到字幕编辑框""" + srt_path = os.path.join(self.pipeline.subs_dir, 'v1_content.srt') + if os.path.exists(srt_path): + with open(srt_path, 'r', encoding='utf-8') as f: + self.subtitle_edit.setPlainText(f.read()) + else: + self.subtitle_edit.setPlainText("# 字幕文件不存在") + + def _on_apply(self): + """ + 应用按钮:收集所有修改,调用底层原子操作,重烧最终视频。 + + 逻辑: + 1. 检查字幕是否有修改(对比 v1_content.srt) + - 有修改 → reburn_subtitles(user_texts) + 2. 检查 clips 是否有修改 + - clips 修改后(add/delete/reextract)已通过各自的方法自动保存 + - 但需要重新烧录标题轨 → reburn_titles() + 3. 最后合并+烧录最终视频 + """ + self.apply_btn.setEnabled(False) + self.status_label.setText("正在应用更改...") + + def work(): + try: + changed = False + + # 1. 处理字幕修改 + srt_path = os.path.join(self.pipeline.subs_dir, 'v1_content.srt') + current_text = self.subtitle_edit.toPlainText() + + if os.path.exists(srt_path): + with open(srt_path, 'r', encoding='utf-8') as f: + original_text = f.read() + else: + original_text = "" + + if current_text != original_text: + # 字幕有修改,写入并重烧 + self.signaller.log_signal.emit("检测到字幕修改,保存并重烧...") + self.status_label.setText("保存字幕更改...") + with open(srt_path, 'w', encoding='utf-8') as f: + f.write(current_text) + self.pipeline.reburn_subtitles(current_text) + changed = True + + # 2. 检查是否有 unmatched clips(不参与烧录) + unmatched = [i for i, c in enumerate(self.pipeline.clips) if not c.get('matched', True)] + if unmatched: + self.signaller.log_signal.emit(f"警告: {len(unmatched)} 个 clip 未匹配到转录内容,不参与烧录") + self.status_label.setText(f"警告: {len(unmatched)} 个 clip 未匹配,跳过") + + # 3. 只要 clips 有任何修改(add/delete/reextract),都需要重烧标题轨 + # 这些修改已经在各自的方法里保存了 config,只需要重烧 + # reburn_titles() 会用最新的 clips config 生成标题轨 + self.signaller.log_signal.emit("重烧标题轨...") + self.status_label.setText("重烧标题轨...") + self.pipeline.reburn_titles() + changed = True + + if changed: + self.signaller.log_signal.emit("应用完成") + self.status_label.setText("应用完成!") + else: + self.signaller.log_signal.emit("没有检测到更改") + self.status_label.setText("没有更改") + + except Exception as e: + self.signaller.log_signal.emit(f"错误: {e}") + import traceback + self.signaller.log_signal.emit(traceback.format_exc()) + self.status_label.setText(f"错误: {e}") + finally: + self.apply_btn.setEnabled(True) + + threading.Thread(target=work, daemon=True).start() + + def _setup_new_project_ui(self): + """新建项目的文件选择界面""" + self.setWindowTitle("新建项目 - Piano Highlight Generator") self.setGeometry(100, 100, 700, 500) + self._clear_ui() central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) @@ -124,12 +465,11 @@ class GUI(QMainWindow): # === 按钮区 === btn_layout = QHBoxLayout() self.start_btn = QPushButton("开始处理") - self.start_btn.clicked.connect(self._on_start) - self.clear_btn = QPushButton("清空日志") - self.clear_btn.clicked.connect(lambda: self.log_area.clear()) + self.start_btn.clicked.connect(self._on_start_new) + self.back_btn = QPushButton("返回") + self.back_btn.clicked.connect(self._setup_start_page) btn_layout.addWidget(self.start_btn) - btn_layout.addWidget(self.clear_btn) - btn_layout.addStretch() + btn_layout.addWidget(self.back_btn) layout.addLayout(btn_layout) def _select_file(self, edit, filter_str): @@ -156,8 +496,8 @@ class GUI(QMainWindow): else: QMessageBox.critical(self, "错误", message) - def _on_start(self): - # 收集参数 + def _on_start_new(self): + """开始新建项目流程""" video_path = self.video_edit.text().strip() ppt_path = self.ppt_edit.text().strip() output_dir = self.output_edit.text().strip() @@ -182,12 +522,13 @@ class GUI(QMainWindow): # 后台线程执行 self.worker_thread = threading.Thread( - target=self._worker, + target=self._worker_new, args=(video_path, ppt_path, output_dir, api_key, api_host) ) self.worker_thread.start() - def _worker(self, video_path, ppt_path, output_dir, api_key, api_host): + def _worker_new(self, video_path, ppt_path, output_dir, api_key, api_host): + """新建项目的工作线程""" try: os.makedirs(output_dir, exist_ok=True) @@ -217,7 +558,6 @@ class GUI(QMainWindow): # 保存配置 config_path = os.path.join(output_dir, 'generated_config.yaml') - import yaml with open(config_path, 'w', encoding='utf-8') as f: yaml.dump(config, f, allow_unicode=True, default_flow_style=False) self.signaller.log_signal.emit(f"配置已保存: {config_path}")