Compare commits

...

3 Commits

Author SHA1 Message Date
hmo 9d0ed5820d 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
2026-05-04 01:06:59 +08:00
hmo 086ce358a5 refactor: separate config.py into global (API) vs project (paths) 2026-05-04 00:14:23 +08:00
hmo 3325752f02 feat: add ppt_path and max_total_duration to generated_config.yaml 2026-05-04 00:11:59 +08:00
7 changed files with 762 additions and 50 deletions
+14 -7
View File
@@ -4,18 +4,17 @@
直接用已有的 clips + title.srt + content.srt 合并烧录 直接用已有的 clips + title.srt + content.srt 合并烧录
用法: 用法:
python burn_only.py
python burn_only.py "D:\\path\\to\\output_dir" python burn_only.py "D:\\path\\to\\output_dir"
""" """
import sys import sys
import os import os
# 导入统一配置 # 必须指定输出目录
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) if len(sys.argv) < 2:
import config print("用法: python burn_only.py <output_dir>")
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") TITLE_SRT = os.path.join(OUTPUT, "subs", "v1_title.srt")
@@ -40,7 +39,15 @@ src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
sys.path.insert(0, src_dir) sys.path.insert(0, src_dir)
from core import Pipeline from core import Pipeline
# 构造 minimal config(只需要 output_dir 和 video_params # 尝试从 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 = { pipeline_config = {
'output_dir': OUTPUT, 'output_dir': OUTPUT,
'clips': [], 'clips': [],
+2 -11
View File
@@ -1,20 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
统一配置 - 修改这里即可,不要改 run.py / burn_only.py / *.bat 全局配置 - API密钥和环境配置,不含项目路径
所有路径和 API 配置集中管理 项目路径(VIDEO/PPT/OUTPUT等)请直接在 run.py / burn_only.py 中定义
""" """
import os import os
# ========== 路径配置 ==========
VIDEO = r"D:\F\yc\课程上架\福田商圈夜校\课程视频\直播回放-03月18日.mp4"
PPT = r"D:\F\yc\课程上架\福田商圈夜校\课程视频\钢琴演奏入门第一课.pptx"
OUTPUT = r"D:\F\NewI\opencode\daily-workspace\projects\piano-lesson-highlights\cases\lesson1\output_cli_full"
LOG_FILE = r"D:\F\NewI\opencode\daily-workspace\temp\cli_run_log.txt"
# ========== 运行参数 ==========
MAX_TOTAL_DURATION = 600 # 精华片段总时长上限(秒)
# ========== API 配置 ========== # ========== API 配置 ==========
API_KEY = "b0359bed-09f2-49e2-a53c-32ba057412e3" API_KEY = "b0359bed-09f2-49e2-a53c-32ba057412e3"
API_HOST = "https://ark.cn-beijing.volces.com/api/coding/v3" API_HOST = "https://ark.cn-beijing.volces.com/api/coding/v3"
+68
View File
@@ -115,3 +115,71 @@ A: 直接改 `v1_title.srt` 里的时间轴,或者改 `generated_config.yaml`
**Q: 想删掉某个 clip** **Q: 想删掉某个 clip**
A: 从 `generated_config.yaml` 里删掉那一条,然后删对应 `intermediates/clip*.json``clip*.mp4`,最后 `run.bat` 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 自动判断需要重生成哪些部分。
+16 -9
View File
@@ -1,35 +1,42 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
完整流水线 - 从 PPT 解析到最终视频输出 完整流水线 - 从 PPT 解析到最终视频输出
配置统一在 config.py 中管理。
项目路径在此文件中定义,config.py 只提供 API 和环境配置。
""" """
import sys import sys
import os import os
import subprocess import subprocess
# 导入统一配置 # 导入全局配置(API Key、环境等)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import config import config
# ========== 项目路径配置(按需修改)==========
VIDEO = r"D:\F\yc\课程上架\福田商圈夜校\课程视频\直播回放-03月18日.mp4"
PPT = r"D:\F\yc\课程上架\福田商圈夜校\课程视频\钢琴演奏入门第一课.pptx"
OUTPUT = r"D:\F\NewI\opencode\daily-workspace\projects\piano-lesson-highlights\cases\lesson1\output_cli_full"
MAX_TOTAL_DURATION = 600 # 精华片段总时长上限(秒)
env = os.environ.copy() env = os.environ.copy()
env["PATH"] = os.path.dirname(config.PYTHON) + ";" + env.get("PATH", "") env["PATH"] = os.path.dirname(config.PYTHON) + ";" + env.get("PATH", "")
cmd = [ cmd = [
config.PYTHON, config.PYTHON,
os.path.join(config.CLI_DIR, "src", "cli.py"), os.path.join(config.CLI_DIR, "src", "cli.py"),
"--video", config.VIDEO, "--video", VIDEO,
"--ppt", config.PPT, "--ppt", PPT,
"--output", config.OUTPUT, "--output", OUTPUT,
"--api-key", config.API_KEY, "--api-key", config.API_KEY,
"--api-host", config.API_HOST, "--api-host", config.API_HOST,
"--max-total-duration", str(config.MAX_TOTAL_DURATION), "--max-total-duration", str(MAX_TOTAL_DURATION),
"--verbose", "--verbose",
] ]
print(f"Running pipeline...") print(f"Running pipeline...")
print(f" Video: {config.VIDEO}") print(f" Video: {VIDEO}")
print(f" PPT: {config.PPT}") print(f" PPT: {PPT}")
print(f" Output: {config.OUTPUT}") print(f" Output: {OUTPUT}")
print() print()
proc = subprocess.Popen(cmd, cwd=config.CLI_DIR, env=env) proc = subprocess.Popen(cmd, cwd=config.CLI_DIR, env=env)
+273
View File
@@ -482,6 +482,279 @@ class Pipeline:
self.step_callback('burning') self.step_callback('burning')
return final_path 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: def run(self) -> str:
+26
View File
@@ -963,6 +963,30 @@ class PPTParser:
logger.warning(f"LLM提取片段失败: {e}") logger.warning(f"LLM提取片段失败: {e}")
return None 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: def run(self) -> dict:
@@ -1054,6 +1078,8 @@ class PPTParser:
"""创建配置字典""" """创建配置字典"""
return { return {
"video_src": self.video_path, "video_src": self.video_path,
"ppt_path": self.ppt_path,
"max_total_duration": self.max_total_duration,
"clips": clips, "clips": clips,
"output_dir": self.output_dir, "output_dir": self.output_dir,
"term_corrections": self.term_corrections, "term_corrections": self.term_corrections,
+353 -13
View File
@@ -10,6 +10,7 @@ import os
import threading import threading
import logging import logging
import configparser import configparser
import yaml
from pathlib import Path from pathlib import Path
# 设置环境 # 设置环境
@@ -23,9 +24,11 @@ if ffmpeg_path:
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLineEdit, QPushButton, QTextEdit, QLabel, QFileDialog, 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.QtCore import Qt, Signal, QObject
from PySide6.QtGui import QColor
# 底层函数(与 CLI 共用) # 底层函数(与 CLI 共用)
from core import parse_ppt_to_config, Pipeline from core import parse_ppt_to_config, Pipeline
@@ -63,16 +66,354 @@ class GUI(QMainWindow):
self.signaller = Signaller() self.signaller = Signaller()
self.worker_thread = None 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.log_signal.connect(self._append_log)
self.signaller.finished_signal.connect(self._on_finished) 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.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"<b>项目目录:</b> {self.project_config.get('output_dir', 'N/A')}"))
# 视频源
video_src = self.project_config.get('video_src', 'N/A')
layout.addWidget(QLabel(f"<b>视频:</b> {video_src}"))
# PPT 源
ppt_src = self.project_config.get('ppt_src', 'N/A')
layout.addWidget(QLabel(f"<b>PPT:</b> {ppt_src}"))
# Clips 数量
clips = self.project_config.get('clips', [])
layout.addWidget(QLabel(f"<b>Clips 数量:</b> {len(clips)}"))
# Clips 列表
if clips:
list_label = QLabel("<b>Clips 列表:</b>")
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.setGeometry(100, 100, 700, 500)
self._clear_ui()
central = QWidget() central = QWidget()
self.setCentralWidget(central) self.setCentralWidget(central)
layout = QVBoxLayout(central) layout = QVBoxLayout(central)
@@ -124,12 +465,11 @@ class GUI(QMainWindow):
# === 按钮区 === # === 按钮区 ===
btn_layout = QHBoxLayout() btn_layout = QHBoxLayout()
self.start_btn = QPushButton("开始处理") self.start_btn = QPushButton("开始处理")
self.start_btn.clicked.connect(self._on_start) self.start_btn.clicked.connect(self._on_start_new)
self.clear_btn = QPushButton("清空日志") self.back_btn = QPushButton("返回")
self.clear_btn.clicked.connect(lambda: self.log_area.clear()) self.back_btn.clicked.connect(self._setup_start_page)
btn_layout.addWidget(self.start_btn) btn_layout.addWidget(self.start_btn)
btn_layout.addWidget(self.clear_btn) btn_layout.addWidget(self.back_btn)
btn_layout.addStretch()
layout.addLayout(btn_layout) layout.addLayout(btn_layout)
def _select_file(self, edit, filter_str): def _select_file(self, edit, filter_str):
@@ -156,8 +496,8 @@ class GUI(QMainWindow):
else: else:
QMessageBox.critical(self, "错误", message) QMessageBox.critical(self, "错误", message)
def _on_start(self): def _on_start_new(self):
# 收集参数 """开始新建项目流程"""
video_path = self.video_edit.text().strip() video_path = self.video_edit.text().strip()
ppt_path = self.ppt_edit.text().strip() ppt_path = self.ppt_edit.text().strip()
output_dir = self.output_edit.text().strip() output_dir = self.output_edit.text().strip()
@@ -182,12 +522,13 @@ class GUI(QMainWindow):
# 后台线程执行 # 后台线程执行
self.worker_thread = threading.Thread( self.worker_thread = threading.Thread(
target=self._worker, target=self._worker_new,
args=(video_path, ppt_path, output_dir, api_key, api_host) args=(video_path, ppt_path, output_dir, api_key, api_host)
) )
self.worker_thread.start() 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: try:
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
@@ -217,7 +558,6 @@ class GUI(QMainWindow):
# 保存配置 # 保存配置
config_path = os.path.join(output_dir, 'generated_config.yaml') config_path = os.path.join(output_dir, 'generated_config.yaml')
import yaml
with open(config_path, 'w', encoding='utf-8') as f: with open(config_path, 'w', encoding='utf-8') as f:
yaml.dump(config, f, allow_unicode=True, default_flow_style=False) yaml.dump(config, f, allow_unicode=True, default_flow_style=False)
self.signaller.log_signal.emit(f"配置已保存: {config_path}") self.signaller.log_signal.emit(f"配置已保存: {config_path}")