Compare commits
9 Commits
9d0ed5820d
...
0720a7657a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0720a7657a | |||
| 2505f88b74 | |||
| b9dc5b163b | |||
| 5f3396378f | |||
| c99d05e42e | |||
| fc76ded3e4 | |||
| 440f481599 | |||
| 0fbf8757fa | |||
| 6a5ec9c04f |
+14
-7
@@ -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': [],
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+353
-13
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user