Compare commits
1 Commits
master
...
9d0ed5820d
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d0ed5820d |
+17
-10
@@ -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 <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")
|
||||
CONTENT_SRT = os.path.join(OUTPUT, "subs", "v1_content.srt")
|
||||
@@ -40,8 +39,16 @@ 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 = {
|
||||
# 尝试从 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,
|
||||
@@ -49,7 +56,7 @@ pipeline_config = {
|
||||
'term_corrections': {},
|
||||
'api_key': '',
|
||||
'api_host': '',
|
||||
}
|
||||
}
|
||||
|
||||
pipeline = Pipeline(pipeline_config)
|
||||
|
||||
|
||||
@@ -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 自动判断需要重生成哪些部分。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+353
-13
@@ -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"<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._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}")
|
||||
|
||||
Reference in New Issue
Block a user