github-actions[bot]
Deploy from GitHub Actions - 2025-11-15 11:44:56
db4f540
import sys
sys.path.insert(0, "/app/src")
"""
Auto-Chapter-Bar Web Interface v2 with Interactive Editor
支持 AI 生成后编辑章节
"""
import os
import tempfile
import gradio as gr
import pandas as pd
from chapterbar.chapter_extractor import (
BASE_COLOR,
Chapter,
extract_chapters_ai,
extract_chapters_auto,
)
from chapterbar.chapter_validator import ChapterValidator
from chapterbar.generator import generate_video
from chapterbar.parser import parse_srt
def format_time(seconds: float) -> str:
"""格式化时间为 mm:ss"""
minutes = int(seconds // 60)
secs = int(seconds % 60)
return f"{minutes:02d}:{secs:02d}"
def parse_time(time_str: str) -> float:
"""解析时间字符串(mm:ss 或秒数)"""
time_str = time_str.strip()
if ":" in time_str:
parts = time_str.split(":")
if len(parts) == 2:
return int(parts[0]) * 60 + int(parts[1])
return float(time_str)
def chapters_to_dataframe(chapters: list[Chapter]) -> pd.DataFrame:
"""将章节列表转换为 DataFrame"""
data = []
for i, ch in enumerate(chapters, 1):
data.append(
{
"序号": i,
"开始时间": format_time(ch.start_time),
"结束时间": format_time(ch.end_time),
"标题": ch.title,
}
)
return pd.DataFrame(data)
def dataframe_to_chapters(df: pd.DataFrame, duration: float) -> tuple[list[Chapter], list[str]]:
"""将 DataFrame 转换为章节列表,并验证"""
chapters = []
for _, row in df.iterrows():
try:
start_time = parse_time(str(row["开始时间"]))
end_time = parse_time(str(row["结束时间"]))
title = str(row["标题"])
chapter = Chapter(title=title, start_time=start_time, end_time=end_time, color=BASE_COLOR)
chapters.append(chapter)
except Exception as e:
return [], [f"解析第 {row['序号']} 行失败: {str(e)}"]
# 验证章节
validator = ChapterValidator(chapters, duration)
is_valid, errors, warnings = validator.validate()
if errors:
error_messages = [err.message for err in errors]
return chapters, error_messages
return chapters, []
def extract_duration_from_srt(srt_file_path: str) -> float | None:
"""从 SRT 文件提取时长"""
try:
entries = parse_srt(srt_file_path)
if not entries:
return None
return entries[-1].end_time
except Exception as e:
print(f"提取时长失败: {e}")
return None
def generate_chapters(srt_file, mode: str, interval: int, api_key: str, model: str) -> tuple[pd.DataFrame, str, float]:
"""生成章节列表
返回: (章节DataFrame, 状态消息, 视频时长)
"""
try:
if not srt_file:
return pd.DataFrame(), "❌ 请先上传 SRT 文件", 0
# 解析 SRT
file_path = srt_file.name if hasattr(srt_file, "name") else srt_file
entries = parse_srt(file_path)
if not entries:
return pd.DataFrame(), "❌ SRT 文件解析失败", 0
# 获取时长
duration = extract_duration_from_srt(file_path)
if not duration:
return pd.DataFrame(), "❌ 无法获取视频时长", 0
# 提取章节
if mode == "ai":
if not api_key or not api_key.strip():
return pd.DataFrame(), "❌ AI 模式需要提供 API Key", duration
chapters = extract_chapters_ai(entries, duration, api_key, model)
else:
chapters = extract_chapters_auto(entries, interval, duration)
if not chapters:
return pd.DataFrame(), "❌ 章节提取失败", duration
# 转换为 DataFrame
df = chapters_to_dataframe(chapters)
mode_name = "AI 智能分段" if mode == "ai" else "固定间隔"
status = f"✅ 成功生成 {len(chapters)} 个章节({mode_name})\n📏 视频时长: {duration:.2f} 秒"
return df, status, duration
except Exception as e:
return pd.DataFrame(), f"❌ 生成失败: {str(e)}", 0
def generate_video_from_chapters(
chapters_df: pd.DataFrame, duration: float, width: int, height: int
) -> tuple[str, str | None]:
"""从章节 DataFrame 生成视频
返回: (状态消息, 视频路径)
"""
try:
if chapters_df.empty:
return "❌ 章节列表为空,请先生成章节", None
if duration <= 0:
return "❌ 视频时长无效", None
# 转换为章节列表并验证
chapters, errors = dataframe_to_chapters(chapters_df, duration)
if errors:
error_msg = "❌ 验证失败:\n" + "\n".join(f" • {err}" for err in errors)
return error_msg, None
# 生成视频
with tempfile.NamedTemporaryFile(suffix=".mov", delete=False) as tmp_file:
output_path = tmp_file.name
generate_video(
chapters=chapters,
duration=duration,
output_path=output_path,
width=width,
height=height,
)
if not os.path.exists(output_path):
return "❌ 视频生成失败", None
return (
f"✅ 视频生成成功!\n📊 共 {len(chapters)} 个章节\n📏 时长: {duration:.2f} 秒",
output_path,
)
except Exception as e:
return f"❌ 生成失败: {str(e)}", None
def sort_and_renumber_chapters(chapters_df: pd.DataFrame) -> tuple[pd.DataFrame, str]:
"""整理章节:按开始时间排序并重新编号"""
try:
if chapters_df.empty:
return chapters_df, "❌ 章节列表为空"
# 移除空行
chapters_df = chapters_df.dropna(subset=["标题"]).reset_index(drop=True)
if chapters_df.empty:
return chapters_df, "❌ 没有有效的章节"
# 解析时间并排序
chapters_df["_start_seconds"] = chapters_df["开始时间"].apply(lambda x: parse_time(str(x)))
chapters_df = chapters_df.sort_values("_start_seconds").reset_index(drop=True)
chapters_df = chapters_df.drop("_start_seconds", axis=1)
# 重新编号
chapters_df["序号"] = range(1, len(chapters_df) + 1)
return chapters_df, f"✅ 已整理 {len(chapters_df)} 个章节"
except Exception as e:
return chapters_df, f"❌ 整理失败: {str(e)}"
def validate_chapters_only(chapters_df: pd.DataFrame, duration: float) -> str:
"""仅验证章节,不生成视频"""
try:
if chapters_df.empty:
return "❌ 章节列表为空"
if duration <= 0:
return "❌ 视频时长无效"
# 转换并验证
chapters, errors = dataframe_to_chapters(chapters_df, duration)
if errors:
error_msg = "❌ 验证失败:\n" + "\n".join(f" • {err}" for err in errors)
return error_msg
# 获取警告
validator = ChapterValidator(chapters, duration)
is_valid, _, warnings = validator.validate()
if warnings:
warning_msg = "\n⚠️ 警告:\n" + "\n".join(f" • {w.message}" for w in warnings)
return f"✅ 验证通过!共 {len(chapters)} 个章节{warning_msg}"
return f"✅ 验证通过!共 {len(chapters)} 个章节,无警告"
except Exception as e:
return f"❌ 验证失败: {str(e)}"
def create_interface():
"""创建 Gradio 界面"""
with gr.Blocks(title="Auto-Chapter-Bar v2", theme=gr.themes.Soft()) as app:
# 状态变量
duration_state = gr.State(0.0)
gr.Markdown(
"""
# 🎬 [Auto-Chapter-Bar](https://github.com/bbruceyuan/auto-chapter-bar)
### 将 SRT 字幕文件转换为可叠加的视频章节进度条动画
**使用说明**:上传 SRT 文件 → 设置参数 → 点击生成 → 下载透明视频
**特性**:
* AI 智能分段(需要 Moonshot API Key)
* 固定间隔分段(免费)
* 透明通道输出(直接叠加到原视频)
* 支持中文字幕
"""
)
with gr.Row():
# 左侧:输入和设置
with gr.Column(scale=1):
gr.Markdown("### 1️⃣ 上传文件")
srt_file = gr.File(label="SRT 字幕文件", file_types=[".srt"], file_count="single")
gr.Markdown("### 2️⃣ 生成章节")
mode = gr.Radio(
label="提取模式",
choices=[("固定间隔", "auto"), ("AI 智能分段", "ai")],
value="auto",
)
with gr.Group() as auto_group:
interval = gr.Slider(label="间隔(秒)", minimum=30, maximum=300, value=60, step=30)
with gr.Group(visible=False) as ai_group:
api_key = gr.Textbox(label="API Key", type="password", placeholder="sk-...")
model = gr.Dropdown(
label="模型",
choices=["moonshot-v1-8k", "moonshot-v1-32k"],
value="moonshot-v1-8k",
)
generate_chapters_btn = gr.Button("🎯 生成章节", variant="primary", size="lg")
status_gen = gr.Textbox(label="生成状态", lines=3, interactive=False)
# 右侧:章节编辑和生成
with gr.Column(scale=1):
gr.Markdown("### 3️⃣ 编辑章节")
gr.Markdown(
"""
**编辑说明**:
- 📝 **直接编辑**: 双击单元格修改内容
- ➕ **添加行**: 点击表格右上角的 ➕ 按钮
- 🗑️ **删除行**: 选中行后点击表格右上角的 🗑️ 按钮
- 🔄 **重新排序**: 编辑后点击"整理章节"按钮
"""
)
chapters_table = gr.Dataframe(
headers=["序号", "开始时间", "结束时间", "标题"],
datatype=["number", "str", "str", "str"],
label="章节列表",
interactive=True,
wrap=True,
row_count=(1, "dynamic"), # 允许动态添加行
col_count=(4, "fixed"),
)
with gr.Row():
sort_btn = gr.Button("🔄 整理章节(按时间排序并重新编号)", size="sm")
validate_btn = gr.Button("✅ 验证章节", size="sm", variant="secondary")
status_edit = gr.Textbox(label="编辑状态", lines=3, interactive=False)
gr.Markdown("### 4️⃣ 生成视频")
with gr.Row():
width = gr.Number(label="宽度", value=1920, minimum=640)
height = gr.Number(label="高度", value=60, minimum=40)
generate_video_btn = gr.Button("🎬 生成视频", variant="primary", size="lg")
status_video = gr.Textbox(label="生成状态", lines=3, interactive=False)
output_video = gr.Video(label="预览")
download_file = gr.File(label="下载")
gr.Markdown(
"""
---
### 💡 使用提示
1. **时间格式**: 支持 `mm:ss` (如 `01:30`) 或秒数 (如 `90`)
2. **直接编辑**: 点击表格单元格可直接修改
3. **验证**: 生成视频时会自动检查时间重叠和间隙
4. **保存**: 编辑后的章节会在生成视频时使用
---
### 💡 其他
**固定间隔模式(推荐新手)**:
- 免费使用,无需 API Key
- 适合结构均匀的教程、课程类视频
**AI 智能分段模式(推荐高质量内容)**:
- 需要 Moonshot API Key
- 自动识别主题转换点,生成更自然的章节
- 成本约 ¥0.05/视频(5 分钟时长)
**后续步骤**:
1. 下载生成的 `.mov` 文件(透明通道)
2. 在 PR/剪映/达芬奇中导入原视频
3. 将章节条拖到最上层轨道
4. 导出最终视频
**GitHub 仓库**: [https://github.com/bbruceyuan/auto-chapter-bar](https://github.com/bbruceyuan/auto-chapter-bar)
---
Made with ❤️ by [Chaofa Yuan](https://yuanchaofa.com)
"""
)
# 事件处理
def toggle_mode(mode_value):
if mode_value == "ai":
return gr.Group(visible=False), gr.Group(visible=True)
return gr.Group(visible=True), gr.Group(visible=False)
mode.change(fn=toggle_mode, inputs=[mode], outputs=[auto_group, ai_group])
generate_chapters_btn.click(
fn=generate_chapters,
inputs=[srt_file, mode, interval, api_key, model],
outputs=[chapters_table, status_gen, duration_state],
)
sort_btn.click(
fn=sort_and_renumber_chapters,
inputs=[chapters_table],
outputs=[chapters_table, status_edit],
)
validate_btn.click(
fn=validate_chapters_only,
inputs=[chapters_table, duration_state],
outputs=[status_edit],
)
def generate_and_display(df, dur, w, h):
status, video_path = generate_video_from_chapters(df, dur, int(w), int(h))
return status, video_path, video_path
generate_video_btn.click(
fn=generate_and_display,
inputs=[chapters_table, duration_state, width, height],
outputs=[status_video, output_video, download_file],
)
return app
if __name__ == "__main__":
app = create_interface()
app.launch()