Spaces:
Sleeping
Sleeping
| 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() | |