github-actions[bot] commited on
Commit
db4f540
·
0 Parent(s):

Deploy from GitHub Actions - 2025-11-15 11:44:56

Browse files
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ *.mov
5
+ *.mp4
6
+ outputs/
7
+ inputs/
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Auto Chapter Bar
3
+ emoji: 🎬
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: gradio
7
+ python_version: "3.13"
8
+ sdk_version: "5.9.1"
9
+ app_file: app.py
10
+ pinned: false
11
+ license: apache-2.0
12
+ ---
13
+
14
+ # 🎬 Auto Chapter Bar
15
+
16
+ Auto Chapter Bar(简称 `acb`)是一个开源的 Python 工具,可以快速将 SRT 字幕文件转换为带有 Alpha 透明通道的视频章节进度条。
17
+
18
+ ## ✨ 核心特性
19
+
20
+ - **🎨 透明通道支持**:输出 RGBA 格式,可完美叠加在任意视频上
21
+ - **🤖 AI 智能分段**:基于 Moonshot LLM 理解语义边界,自动识别章节
22
+ - **🔒 隐私优先**:完全本地处理,视频文件不上传云端
23
+ - **⚡ 高性能**:多进程并行处理,速度提升 2-4 倍
24
+ - **🎛️ 三种模式**:AI 智能模式、自动分段模式、手动配置模式
25
+
26
+ ## 🚀 使用方法
27
+
28
+ 1. 上传 SRT 字幕文件
29
+ 2. 输入视频总时长(秒)
30
+ 3. 选择生成模式(AI 或 Auto)
31
+ 4. 点击生成按钮
32
+ 5. 下载生成的章节进度条视频
33
+
34
+ ## 📦 在视频编辑软件中使用
35
+
36
+ 生成的视频带有透明通道(Alpha Channel),可以直接叠加在原视频上:
37
+
38
+ - **Adobe Premiere Pro**:导入后放在最上层视频轨道
39
+ - **剪映(CapCut)**:添加为画中画轨道
40
+ - **DaVinci Resolve**:放在视频轨道最上层
41
+
42
+ ## 🔗 项目链接
43
+
44
+ - **GitHub**: https://github.com/bbruceyuan/auto-chapter-bar
45
+ - **文档**: 查看 GitHub 仓库获取完整文档
46
+ - **许可证**: Apache License 2.0
47
+
48
+ ---
49
+
50
+ ⭐ 如果觉得这个项目有帮助,请在 GitHub 给个 Star!
app.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ sys.path.insert(0, "/app/src")
3
+ """
4
+ Auto-Chapter-Bar Web Interface v2 with Interactive Editor
5
+ 支持 AI 生成后编辑章节
6
+ """
7
+
8
+ import os
9
+ import tempfile
10
+
11
+ import gradio as gr
12
+ import pandas as pd
13
+
14
+ from chapterbar.chapter_extractor import (
15
+ BASE_COLOR,
16
+ Chapter,
17
+ extract_chapters_ai,
18
+ extract_chapters_auto,
19
+ )
20
+ from chapterbar.chapter_validator import ChapterValidator
21
+ from chapterbar.generator import generate_video
22
+ from chapterbar.parser import parse_srt
23
+
24
+
25
+ def format_time(seconds: float) -> str:
26
+ """格式化时间为 mm:ss"""
27
+ minutes = int(seconds // 60)
28
+ secs = int(seconds % 60)
29
+ return f"{minutes:02d}:{secs:02d}"
30
+
31
+
32
+ def parse_time(time_str: str) -> float:
33
+ """解析时间字符串(mm:ss 或秒数)"""
34
+ time_str = time_str.strip()
35
+ if ":" in time_str:
36
+ parts = time_str.split(":")
37
+ if len(parts) == 2:
38
+ return int(parts[0]) * 60 + int(parts[1])
39
+ return float(time_str)
40
+
41
+
42
+ def chapters_to_dataframe(chapters: list[Chapter]) -> pd.DataFrame:
43
+ """将章节列表转换为 DataFrame"""
44
+ data = []
45
+ for i, ch in enumerate(chapters, 1):
46
+ data.append(
47
+ {
48
+ "序号": i,
49
+ "开始时间": format_time(ch.start_time),
50
+ "结束时间": format_time(ch.end_time),
51
+ "标题": ch.title,
52
+ }
53
+ )
54
+ return pd.DataFrame(data)
55
+
56
+
57
+ def dataframe_to_chapters(df: pd.DataFrame, duration: float) -> tuple[list[Chapter], list[str]]:
58
+ """将 DataFrame 转换为章节列表,并验证"""
59
+ chapters = []
60
+ for _, row in df.iterrows():
61
+ try:
62
+ start_time = parse_time(str(row["开始时间"]))
63
+ end_time = parse_time(str(row["结束时间"]))
64
+ title = str(row["标题"])
65
+
66
+ chapter = Chapter(title=title, start_time=start_time, end_time=end_time, color=BASE_COLOR)
67
+ chapters.append(chapter)
68
+ except Exception as e:
69
+ return [], [f"解析第 {row['序号']} 行失败: {str(e)}"]
70
+
71
+ # 验证章节
72
+ validator = ChapterValidator(chapters, duration)
73
+ is_valid, errors, warnings = validator.validate()
74
+
75
+ if errors:
76
+ error_messages = [err.message for err in errors]
77
+ return chapters, error_messages
78
+
79
+ return chapters, []
80
+
81
+
82
+ def extract_duration_from_srt(srt_file_path: str) -> float | None:
83
+ """从 SRT 文件提取时长"""
84
+ try:
85
+ entries = parse_srt(srt_file_path)
86
+ if not entries:
87
+ return None
88
+ return entries[-1].end_time
89
+ except Exception as e:
90
+ print(f"提取时长失败: {e}")
91
+ return None
92
+
93
+
94
+ def generate_chapters(srt_file, mode: str, interval: int, api_key: str, model: str) -> tuple[pd.DataFrame, str, float]:
95
+ """生成章节列表
96
+
97
+ 返回: (章节DataFrame, 状态消息, 视频时长)
98
+ """
99
+ try:
100
+ if not srt_file:
101
+ return pd.DataFrame(), "❌ 请先上传 SRT 文件", 0
102
+
103
+ # 解析 SRT
104
+ file_path = srt_file.name if hasattr(srt_file, "name") else srt_file
105
+ entries = parse_srt(file_path)
106
+ if not entries:
107
+ return pd.DataFrame(), "❌ SRT 文件解析失败", 0
108
+
109
+ # 获取时长
110
+ duration = extract_duration_from_srt(file_path)
111
+ if not duration:
112
+ return pd.DataFrame(), "❌ 无法获取视频时长", 0
113
+
114
+ # 提取章节
115
+ if mode == "ai":
116
+ if not api_key or not api_key.strip():
117
+ return pd.DataFrame(), "❌ AI 模式需要提供 API Key", duration
118
+ chapters = extract_chapters_ai(entries, duration, api_key, model)
119
+ else:
120
+ chapters = extract_chapters_auto(entries, interval, duration)
121
+
122
+ if not chapters:
123
+ return pd.DataFrame(), "❌ 章节提取失败", duration
124
+
125
+ # 转换为 DataFrame
126
+ df = chapters_to_dataframe(chapters)
127
+ mode_name = "AI 智能分段" if mode == "ai" else "固定间隔"
128
+ status = f"✅ 成功生成 {len(chapters)} 个章节({mode_name})\n📏 视频时长: {duration:.2f} 秒"
129
+
130
+ return df, status, duration
131
+
132
+ except Exception as e:
133
+ return pd.DataFrame(), f"❌ 生成失败: {str(e)}", 0
134
+
135
+
136
+ def generate_video_from_chapters(
137
+ chapters_df: pd.DataFrame, duration: float, width: int, height: int
138
+ ) -> tuple[str, str | None]:
139
+ """从章节 DataFrame 生成视频
140
+
141
+ 返回: (状态消息, 视频路径)
142
+ """
143
+ try:
144
+ if chapters_df.empty:
145
+ return "❌ 章节列表为空,请先生成章节", None
146
+
147
+ if duration <= 0:
148
+ return "❌ 视频时长无效", None
149
+
150
+ # 转换为章节列表并验证
151
+ chapters, errors = dataframe_to_chapters(chapters_df, duration)
152
+
153
+ if errors:
154
+ error_msg = "❌ 验证失败:\n" + "\n".join(f" • {err}" for err in errors)
155
+ return error_msg, None
156
+
157
+ # 生成视频
158
+ with tempfile.NamedTemporaryFile(suffix=".mov", delete=False) as tmp_file:
159
+ output_path = tmp_file.name
160
+
161
+ generate_video(
162
+ chapters=chapters,
163
+ duration=duration,
164
+ output_path=output_path,
165
+ width=width,
166
+ height=height,
167
+ )
168
+
169
+ if not os.path.exists(output_path):
170
+ return "❌ 视频生成失败", None
171
+
172
+ return (
173
+ f"✅ 视频生成成功!\n📊 共 {len(chapters)} 个章节\n📏 时长: {duration:.2f} 秒",
174
+ output_path,
175
+ )
176
+
177
+ except Exception as e:
178
+ return f"❌ 生成失败: {str(e)}", None
179
+
180
+
181
+ def sort_and_renumber_chapters(chapters_df: pd.DataFrame) -> tuple[pd.DataFrame, str]:
182
+ """整理章节:按开始时间排序并重新编号"""
183
+ try:
184
+ if chapters_df.empty:
185
+ return chapters_df, "❌ 章节列表为空"
186
+
187
+ # 移除空行
188
+ chapters_df = chapters_df.dropna(subset=["标题"]).reset_index(drop=True)
189
+
190
+ if chapters_df.empty:
191
+ return chapters_df, "❌ 没有有效的章节"
192
+
193
+ # 解析时间并排序
194
+ chapters_df["_start_seconds"] = chapters_df["开始时间"].apply(lambda x: parse_time(str(x)))
195
+ chapters_df = chapters_df.sort_values("_start_seconds").reset_index(drop=True)
196
+ chapters_df = chapters_df.drop("_start_seconds", axis=1)
197
+
198
+ # 重新编号
199
+ chapters_df["序号"] = range(1, len(chapters_df) + 1)
200
+
201
+ return chapters_df, f"✅ 已整理 {len(chapters_df)} 个章节"
202
+ except Exception as e:
203
+ return chapters_df, f"❌ 整理失败: {str(e)}"
204
+
205
+
206
+ def validate_chapters_only(chapters_df: pd.DataFrame, duration: float) -> str:
207
+ """仅验证章节,不生成视频"""
208
+ try:
209
+ if chapters_df.empty:
210
+ return "❌ 章节列表为空"
211
+
212
+ if duration <= 0:
213
+ return "❌ 视频时长无效"
214
+
215
+ # 转换并验证
216
+ chapters, errors = dataframe_to_chapters(chapters_df, duration)
217
+
218
+ if errors:
219
+ error_msg = "❌ 验证失败:\n" + "\n".join(f" • {err}" for err in errors)
220
+ return error_msg
221
+
222
+ # 获取警告
223
+ validator = ChapterValidator(chapters, duration)
224
+ is_valid, _, warnings = validator.validate()
225
+
226
+ if warnings:
227
+ warning_msg = "\n⚠️ 警告:\n" + "\n".join(f" • {w.message}" for w in warnings)
228
+ return f"✅ 验证通过!共 {len(chapters)} 个章节{warning_msg}"
229
+
230
+ return f"✅ 验证通过!共 {len(chapters)} 个章节,无警告"
231
+
232
+ except Exception as e:
233
+ return f"❌ 验证失败: {str(e)}"
234
+
235
+
236
+ def create_interface():
237
+ """创建 Gradio 界面"""
238
+
239
+ with gr.Blocks(title="Auto-Chapter-Bar v2", theme=gr.themes.Soft()) as app:
240
+ # 状态变量
241
+ duration_state = gr.State(0.0)
242
+
243
+ gr.Markdown(
244
+ """
245
+ # 🎬 [Auto-Chapter-Bar](https://github.com/bbruceyuan/auto-chapter-bar)
246
+ ### 将 SRT 字幕文件转换为可叠加的视频章节进度条动画
247
+
248
+ **使用说明**:上传 SRT 文件 → 设置参数 → 点击生成 → 下载透明视频
249
+
250
+ **特性**:
251
+
252
+ * AI 智能分段(需要 Moonshot API Key)
253
+ * 固定间隔分段(免费)
254
+ * 透明通道输出(直接叠加到原视频)
255
+ * 支持中文字幕
256
+ """
257
+ )
258
+
259
+ with gr.Row():
260
+ # 左侧:输入和设置
261
+ with gr.Column(scale=1):
262
+ gr.Markdown("### 1️⃣ 上传文件")
263
+ srt_file = gr.File(label="SRT 字幕文件", file_types=[".srt"], file_count="single")
264
+
265
+ gr.Markdown("### 2️⃣ 生成章节")
266
+ mode = gr.Radio(
267
+ label="提取模式",
268
+ choices=[("固定间隔", "auto"), ("AI 智能分段", "ai")],
269
+ value="auto",
270
+ )
271
+
272
+ with gr.Group() as auto_group:
273
+ interval = gr.Slider(label="间隔(秒)", minimum=30, maximum=300, value=60, step=30)
274
+
275
+ with gr.Group(visible=False) as ai_group:
276
+ api_key = gr.Textbox(label="API Key", type="password", placeholder="sk-...")
277
+ model = gr.Dropdown(
278
+ label="模型",
279
+ choices=["moonshot-v1-8k", "moonshot-v1-32k"],
280
+ value="moonshot-v1-8k",
281
+ )
282
+
283
+ generate_chapters_btn = gr.Button("🎯 生成章节", variant="primary", size="lg")
284
+
285
+ status_gen = gr.Textbox(label="生成状态", lines=3, interactive=False)
286
+
287
+ # 右侧:章节编辑和生成
288
+ with gr.Column(scale=1):
289
+ gr.Markdown("### 3️⃣ 编辑章节")
290
+
291
+ gr.Markdown(
292
+ """
293
+ **编辑说明**:
294
+ - 📝 **直接编辑**: 双击单元格修改内容
295
+ - ➕ **添加行**: 点击表格右上角的 ➕ 按���
296
+ - 🗑️ **删除行**: 选中行后点击表格右上角的 🗑️ 按钮
297
+ - 🔄 **重新排序**: 编辑后点击"整理章节"按钮
298
+ """
299
+ )
300
+
301
+ chapters_table = gr.Dataframe(
302
+ headers=["序号", "开始时间", "结束时间", "标题"],
303
+ datatype=["number", "str", "str", "str"],
304
+ label="章节列表",
305
+ interactive=True,
306
+ wrap=True,
307
+ row_count=(1, "dynamic"), # 允许动态添加行
308
+ col_count=(4, "fixed"),
309
+ )
310
+
311
+ with gr.Row():
312
+ sort_btn = gr.Button("🔄 整理章节(按时间排序并重新编号)", size="sm")
313
+ validate_btn = gr.Button("✅ 验证章节", size="sm", variant="secondary")
314
+
315
+ status_edit = gr.Textbox(label="编辑状态", lines=3, interactive=False)
316
+
317
+ gr.Markdown("### 4️⃣ 生成视频")
318
+
319
+ with gr.Row():
320
+ width = gr.Number(label="宽度", value=1920, minimum=640)
321
+ height = gr.Number(label="高度", value=60, minimum=40)
322
+
323
+ generate_video_btn = gr.Button("🎬 生成视频", variant="primary", size="lg")
324
+
325
+ status_video = gr.Textbox(label="生成状态", lines=3, interactive=False)
326
+
327
+ output_video = gr.Video(label="预览")
328
+ download_file = gr.File(label="下载")
329
+
330
+ gr.Markdown(
331
+ """
332
+ ---
333
+ ### 💡 使用提示
334
+
335
+ 1. **时间格式**: 支持 `mm:ss` (如 `01:30`) 或秒数 (如 `90`)
336
+ 2. **直接编辑**: 点击表格单元格可直接修改
337
+ 3. **验证**: 生成视频时会自动检查时间重叠和间隙
338
+ 4. **保存**: 编辑后的章节会在生成视频时使用
339
+
340
+ ---
341
+
342
+ ### 💡 其他
343
+
344
+ **固定间隔模式(推荐新手)**:
345
+ - 免费使用,无需 API Key
346
+ - 适合结构均匀的教程、课程类视频
347
+
348
+ **AI 智能分段模式(推荐高质量内容)**:
349
+ - 需要 Moonshot API Key
350
+ - 自动识别主题转换点,生成更自然的章节
351
+ - 成本约 ¥0.05/视频(5 分钟时长)
352
+
353
+ **后续步骤**:
354
+ 1. 下载生成的 `.mov` 文件(透明通道)
355
+ 2. 在 PR/剪映/达芬奇中导入原视频
356
+ 3. 将章节条拖到最上层轨道
357
+ 4. 导出最终视频
358
+
359
+ **GitHub 仓库**: [https://github.com/bbruceyuan/auto-chapter-bar](https://github.com/bbruceyuan/auto-chapter-bar)
360
+
361
+ ---
362
+ Made with ❤️ by [Chaofa Yuan](https://yuanchaofa.com)
363
+ """
364
+ )
365
+
366
+ # 事件处理
367
+ def toggle_mode(mode_value):
368
+ if mode_value == "ai":
369
+ return gr.Group(visible=False), gr.Group(visible=True)
370
+ return gr.Group(visible=True), gr.Group(visible=False)
371
+
372
+ mode.change(fn=toggle_mode, inputs=[mode], outputs=[auto_group, ai_group])
373
+
374
+ generate_chapters_btn.click(
375
+ fn=generate_chapters,
376
+ inputs=[srt_file, mode, interval, api_key, model],
377
+ outputs=[chapters_table, status_gen, duration_state],
378
+ )
379
+
380
+ sort_btn.click(
381
+ fn=sort_and_renumber_chapters,
382
+ inputs=[chapters_table],
383
+ outputs=[chapters_table, status_edit],
384
+ )
385
+
386
+ validate_btn.click(
387
+ fn=validate_chapters_only,
388
+ inputs=[chapters_table, duration_state],
389
+ outputs=[status_edit],
390
+ )
391
+
392
+ def generate_and_display(df, dur, w, h):
393
+ status, video_path = generate_video_from_chapters(df, dur, int(w), int(h))
394
+ return status, video_path, video_path
395
+
396
+ generate_video_btn.click(
397
+ fn=generate_and_display,
398
+ inputs=[chapters_table, duration_state, width, height],
399
+ outputs=[status_video, output_video, download_file],
400
+ )
401
+
402
+ return app
403
+
404
+
405
+ if __name__ == "__main__":
406
+ app = create_interface()
407
+ app.launch()
chapterbar/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Auto-Chapter-Bar - 视频章节进度条生成器"""
2
+
3
+ __version__ = "0.1.0"
chapterbar/chapter_extractor.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """章节提取器"""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ import openai
9
+
10
+ from chapterbar.logger import logger
11
+ from chapterbar.parser import SubtitleEntry
12
+
13
+
14
+ @dataclass
15
+ class Chapter:
16
+ """章节信息"""
17
+
18
+ title: str
19
+ start_time: float # 秒
20
+ end_time: float # 秒
21
+ color: tuple[int, int, int] # RGB
22
+
23
+
24
+ # 统一灰色方案(实际颜色在渲染时根据播放状态决定)
25
+ # 未播放:浅灰色 (220, 220, 220)
26
+ # 已播放:深灰色 (140, 140, 140)
27
+ BASE_COLOR = (200, 200, 200) # 默认灰色
28
+
29
+
30
+ def extract_chapters_auto(entries: list[SubtitleEntry], interval: int, total_duration: float) -> list[Chapter]:
31
+ """自动分段模式:按时间间隔分段
32
+
33
+ Args:
34
+ entries: 字幕条目列表
35
+ interval: 分段间隔(秒)
36
+ total_duration: 视频总时长(秒)
37
+
38
+ Returns:
39
+ List[Chapter]: 章节列表
40
+ """
41
+ chapters = []
42
+ current_time = 0
43
+ chapter_index = 0
44
+
45
+ while current_time < total_duration:
46
+ # 计算章节结束时间
47
+ end_time = min(current_time + interval, total_duration)
48
+
49
+ # 找到这个时间段内的字幕文本作为标题
50
+ title_parts = []
51
+ if entries: # 只有当有字幕时才尝试提取
52
+ for entry in entries:
53
+ if current_time <= entry.start_time < end_time:
54
+ title_parts.append(entry.text)
55
+ if len(title_parts) >= 3: # 最多取3条字幕
56
+ break
57
+
58
+ # 生成标题
59
+ title = " ".join(title_parts)[:30] if title_parts else f"章节 {chapter_index + 1}"
60
+
61
+ # 使用统一的基础颜色
62
+ color = BASE_COLOR
63
+
64
+ chapters.append(Chapter(title=title, start_time=current_time, end_time=end_time, color=color))
65
+
66
+ current_time = end_time
67
+ chapter_index += 1
68
+
69
+ return chapters
70
+
71
+
72
+ def format_time(seconds: float) -> str:
73
+ """将秒数转换为 HH:MM:SS 格式"""
74
+ hours = int(seconds // 3600)
75
+ minutes = int((seconds % 3600) // 60)
76
+ secs = int(seconds % 60)
77
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
78
+
79
+
80
+ def extract_chapters_ai(
81
+ entries: list[SubtitleEntry],
82
+ total_duration: float,
83
+ api_key: str | None = None,
84
+ model: str = "moonshot-v1-8k",
85
+ ) -> list[Chapter]:
86
+ """AI 智能分段模式:使用 Moonshot AI 分析字幕内容
87
+
88
+ Args:
89
+ entries: 字幕条目列表
90
+ total_duration: 视频总时长(秒)
91
+ api_key: Moonshot API Key(可选,默认从环境变量读取)
92
+ model: 使用的模型(默认 moonshot-v1-8k)
93
+
94
+ Returns:
95
+ List[Chapter]: 章节列表
96
+ """
97
+ if not entries:
98
+ # 如果没有字幕,回退到自动分段
99
+ return extract_chapters_auto(entries, interval=60, total_duration=total_duration)
100
+
101
+ # 获取 API Key
102
+ if api_key is None:
103
+ api_key = os.getenv("MOONSHOT_API_KEY")
104
+
105
+ if not api_key:
106
+ raise ValueError("未提供 Moonshot API Key,请设置环境变量 MOONSHOT_API_KEY 或通过参数传入")
107
+
108
+ # 初始化客户端
109
+ client = openai.Client(base_url="https://api.moonshot.cn/v1", api_key=api_key)
110
+
111
+ # 构建字幕文本(带时间戳)
112
+ subtitle_lines = []
113
+ for entry in entries:
114
+ time_str = format_time(entry.start_time)
115
+ subtitle_lines.append(f"[{time_str}] {entry.text}")
116
+
117
+ subtitle_text = "\n".join(subtitle_lines)
118
+
119
+ # 构建 prompt
120
+ prompt = f"""请分析以下视频字幕内容,识别内容的主题转换点,并生成合理的章节划分。
121
+
122
+ 要求:
123
+ 1. 识别 5-10 个主要章节(根据内容长度和复杂度调整)
124
+ 2. 每个章节给出开始时间(格式:HH:MM:SS)和简短标题(5-15 字)
125
+ 3. 章节标题要准确概括该段内容的核心主题
126
+ 4. 返回 JSON 格式数组,每个元素包含 time 和 title 字段
127
+ 5. 视频总时长为 {format_time(total_duration)}
128
+
129
+ 示例输出格式:
130
+ [
131
+ {{"time": "00:00:00", "title": "开场与自我介绍"}},
132
+ {{"time": "00:01:23", "title": "问题背景分析"}},
133
+ {{"time": "00:03:45", "title": "解决方案详解"}}
134
+ ]
135
+
136
+ 字幕内容:
137
+ {subtitle_text}
138
+
139
+ 请直接返回 JSON 数组,不要包含其他说明文字。"""
140
+
141
+ # 调用 API
142
+ try:
143
+ response = client.chat.completions.create(
144
+ model=model,
145
+ messages=[
146
+ {
147
+ "role": "system",
148
+ "content": "你是一个专业的视频内容分析助手,擅长识别内容结构和主题转换点。",
149
+ },
150
+ {"role": "user", "content": prompt},
151
+ ],
152
+ temperature=0.3, # 较低的温度以获得更稳定的输出
153
+ max_tokens=2048,
154
+ )
155
+
156
+ # 解析响应
157
+ content = response.choices[0].message.content.strip()
158
+
159
+ # 提取 JSON(可能被包裹在代码块中)
160
+ json_match = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", content, re.DOTALL)
161
+ json_str = json_match.group(1) if json_match else content
162
+
163
+ # 解析 JSON
164
+ chapter_data = json.loads(json_str)
165
+
166
+ # 转换为 Chapter 对象
167
+ chapters = []
168
+ for i, item in enumerate(chapter_data):
169
+ # 解析时间
170
+ time_str = item["time"]
171
+ time_parts = time_str.split(":")
172
+ start_time = int(time_parts[0]) * 3600 + int(time_parts[1]) * 60 + int(time_parts[2])
173
+
174
+ # 计算结束时间
175
+ if i < len(chapter_data) - 1:
176
+ next_time_str = chapter_data[i + 1]["time"]
177
+ next_time_parts = next_time_str.split(":")
178
+ end_time = int(next_time_parts[0]) * 3600 + int(next_time_parts[1]) * 60 + int(next_time_parts[2])
179
+ else:
180
+ end_time = total_duration
181
+
182
+ # 使用统一的基础颜色
183
+ color = BASE_COLOR
184
+
185
+ chapters.append(Chapter(title=item["title"], start_time=start_time, end_time=end_time, color=color))
186
+
187
+ return chapters
188
+
189
+ except json.JSONDecodeError as e:
190
+ logger.warning(f"AI 返回的内容无法解析为 JSON: {e}")
191
+ logger.debug(f"原始内容: {content}")
192
+ # 回退到自动分段
193
+ return extract_chapters_auto(entries, interval=60, total_duration=total_duration)
194
+
195
+ except Exception as e:
196
+ logger.warning(f"AI 分段失败: {e}")
197
+ # 回退到自动分段
198
+ return extract_chapters_auto(entries, interval=60, total_duration=total_duration)
chapterbar/chapter_loader.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """章节配置加载器"""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from chapterbar.chapter_extractor import BASE_COLOR, Chapter
9
+ from chapterbar.chapter_validator import ChapterValidator
10
+
11
+
12
+ class ChapterLoader:
13
+ """章节配置加载器"""
14
+
15
+ @staticmethod
16
+ def load_from_yaml(yaml_path: str) -> tuple[list[Chapter], float]:
17
+ """
18
+ 从 YAML 文件加载章节配置
19
+
20
+ Args:
21
+ yaml_path: YAML 文件路径
22
+
23
+ Returns:
24
+ (chapters, duration)
25
+
26
+ Raises:
27
+ FileNotFoundError: 文件不存在
28
+ ValueError: 配置格式错误或验证失败
29
+ """
30
+ # 检查文件是否存在
31
+ path = Path(yaml_path)
32
+ if not path.exists():
33
+ raise FileNotFoundError(f"配置文件不存在: {yaml_path}")
34
+
35
+ # 读取 YAML
36
+ try:
37
+ with open(yaml_path, encoding="utf-8") as f:
38
+ config = yaml.safe_load(f)
39
+ except yaml.YAMLError as e:
40
+ raise ValueError(f"YAML 格式错误: {e}") from e
41
+
42
+ # 验证配置结构
43
+ if not isinstance(config, dict):
44
+ raise ValueError("配置文件必须是一个字典")
45
+
46
+ if "duration" not in config:
47
+ raise ValueError("配置文件缺少 'duration' 字段")
48
+
49
+ if "chapters" not in config:
50
+ raise ValueError("配置文件缺少 'chapters' 字段")
51
+
52
+ duration = float(config["duration"])
53
+ if duration <= 0:
54
+ raise ValueError(f"视频时长必须大于 0,当前值: {duration}")
55
+
56
+ # 解析章节
57
+ chapters = ChapterLoader._parse_chapters(config["chapters"])
58
+
59
+ # 验证章节
60
+ validator = ChapterValidator(chapters, duration)
61
+ is_valid, errors, warnings = validator.validate()
62
+
63
+ if not is_valid:
64
+ error_messages = [f" - {err.message}" for err in errors]
65
+ raise ValueError("章节配置验证失败:\n" + "\n".join(error_messages))
66
+
67
+ return chapters, duration, warnings
68
+
69
+ @staticmethod
70
+ def _parse_chapters(chapters_data: list[dict[str, Any]]) -> list[Chapter]:
71
+ """
72
+ 解析章节数据
73
+
74
+ Args:
75
+ chapters_data: 章节数据列表
76
+
77
+ Returns:
78
+ Chapter 对象列表
79
+ """
80
+ if not isinstance(chapters_data, list):
81
+ raise ValueError("'chapters' 必须是一个列表")
82
+
83
+ chapters = []
84
+ for i, chapter_data in enumerate(chapters_data):
85
+ if not isinstance(chapter_data, dict):
86
+ raise ValueError(f"章节 {i + 1} 必须是一个字典")
87
+
88
+ # 必需字段
89
+ if "start" not in chapter_data:
90
+ raise ValueError(f"章节 {i + 1} 缺少 'start' 字段")
91
+ if "end" not in chapter_data:
92
+ raise ValueError(f"章节 {i + 1} 缺少 'end' 字段")
93
+ if "title" not in chapter_data:
94
+ raise ValueError(f"章节 {i + 1} 缺少 'title' 字段")
95
+
96
+ # 解析字段
97
+ try:
98
+ start_time = float(chapter_data["start"])
99
+ end_time = float(chapter_data["end"])
100
+ except (ValueError, TypeError) as e:
101
+ raise ValueError(f"章节 {i + 1} 时间格式错误: {e}") from e
102
+
103
+ title = str(chapter_data["title"])
104
+
105
+ # 可选字段:颜色
106
+ if "color" in chapter_data:
107
+ color_data = chapter_data["color"]
108
+ if isinstance(color_data, list) and len(color_data) == 3:
109
+ color = tuple(int(c) for c in color_data)
110
+ else:
111
+ raise ValueError(f"章节 {i + 1} 颜色格式错误,应为 [R, G, B]")
112
+ else:
113
+ color = BASE_COLOR
114
+
115
+ chapters.append(Chapter(title=title, start_time=start_time, end_time=end_time, color=color))
116
+
117
+ return chapters
118
+
119
+ @staticmethod
120
+ def save_to_yaml(chapters: list[Chapter], duration: float, yaml_path: str) -> None:
121
+ """
122
+ 保存章节配置到 YAML 文件
123
+
124
+ Args:
125
+ chapters: 章节列表
126
+ duration: 视频总时长
127
+ yaml_path: 输出文件路径
128
+ """
129
+ config = {"duration": duration, "chapters": []}
130
+
131
+ for chapter in chapters:
132
+ chapter_data = {
133
+ "start": chapter.start_time,
134
+ "end": chapter.end_time,
135
+ "title": chapter.title,
136
+ }
137
+ config["chapters"].append(chapter_data)
138
+
139
+ # 写入文件
140
+ with open(yaml_path, "w", encoding="utf-8") as f:
141
+ yaml.dump(config, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
chapterbar/chapter_validator.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """章节验证器"""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from chapterbar.chapter_extractor import Chapter
6
+
7
+
8
+ @dataclass
9
+ class ValidationError:
10
+ """验证错误"""
11
+
12
+ chapter_index: int
13
+ error_type: str
14
+ message: str
15
+ is_warning: bool = False
16
+
17
+
18
+ class ChapterValidator:
19
+ """章节验证器"""
20
+
21
+ def __init__(self, chapters: list[Chapter], total_duration: float):
22
+ """
23
+ 初始化验证器
24
+
25
+ Args:
26
+ chapters: 章节列表
27
+ total_duration: 视频总时长(秒)
28
+ """
29
+ self.chapters = chapters
30
+ self.total_duration = total_duration
31
+ self.errors: list[ValidationError] = []
32
+ self.warnings: list[ValidationError] = []
33
+
34
+ def validate(self) -> tuple[bool, list[ValidationError], list[ValidationError]]:
35
+ """
36
+ 验证章节列表
37
+
38
+ Returns:
39
+ (is_valid, errors, warnings)
40
+ """
41
+ self.errors = []
42
+ self.warnings = []
43
+
44
+ # 基础检查
45
+ self._check_empty()
46
+ self._check_basic_fields()
47
+
48
+ # 时间检查
49
+ self._check_time_order()
50
+ self._check_time_overlap()
51
+ self._check_time_range()
52
+ self._check_time_gaps()
53
+
54
+ # 内容检查
55
+ self._check_titles()
56
+ self._check_duration()
57
+
58
+ is_valid = len(self.errors) == 0
59
+ return is_valid, self.errors, self.warnings
60
+
61
+ def _check_empty(self):
62
+ """检查章节列表是否为空"""
63
+ if not self.chapters:
64
+ self.errors.append(ValidationError(chapter_index=-1, error_type="empty", message="章节列表为空"))
65
+
66
+ def _check_basic_fields(self):
67
+ """检查基本字段"""
68
+ for i, chapter in enumerate(self.chapters):
69
+ if not hasattr(chapter, "start_time") or chapter.start_time is None:
70
+ self.errors.append(
71
+ ValidationError(
72
+ chapter_index=i,
73
+ error_type="missing_field",
74
+ message=f"章节 {i + 1} 缺少开始时间",
75
+ )
76
+ )
77
+
78
+ if not hasattr(chapter, "end_time") or chapter.end_time is None:
79
+ self.errors.append(
80
+ ValidationError(
81
+ chapter_index=i,
82
+ error_type="missing_field",
83
+ message=f"章节 {i + 1} 缺少结束时间",
84
+ )
85
+ )
86
+
87
+ if not hasattr(chapter, "title") or not chapter.title:
88
+ self.errors.append(
89
+ ValidationError(
90
+ chapter_index=i,
91
+ error_type="missing_field",
92
+ message=f"章节 {i + 1} 缺少标题",
93
+ )
94
+ )
95
+
96
+ def _check_time_order(self):
97
+ """检查时间顺序"""
98
+ for i, chapter in enumerate(self.chapters):
99
+ if chapter.start_time >= chapter.end_time:
100
+ self.errors.append(
101
+ ValidationError(
102
+ chapter_index=i,
103
+ error_type="time_order",
104
+ message=(f"章节 {i + 1} 开始时间 ({chapter.start_time}s) >= 结束时间 ({chapter.end_time}s)"),
105
+ )
106
+ )
107
+
108
+ def _check_time_overlap(self):
109
+ """检查时间重叠"""
110
+ for i in range(len(self.chapters) - 1):
111
+ current = self.chapters[i]
112
+ next_chapter = self.chapters[i + 1]
113
+
114
+ if current.end_time > next_chapter.start_time:
115
+ self.errors.append(
116
+ ValidationError(
117
+ chapter_index=i,
118
+ error_type="time_overlap",
119
+ message=(
120
+ f"章节 {i + 1} 和章节 {i + 2} 时间重叠:章节 {i + 1} 结束于 "
121
+ f"{current.end_time}s,但章节 {i + 2} 开始于 {next_chapter.start_time}s"
122
+ ),
123
+ )
124
+ )
125
+
126
+ def _check_time_range(self):
127
+ """检查时间范围"""
128
+ for i, chapter in enumerate(self.chapters):
129
+ if chapter.start_time < 0:
130
+ self.errors.append(
131
+ ValidationError(
132
+ chapter_index=i,
133
+ error_type="time_range",
134
+ message=f"章节 {i + 1} 开始时间 ({chapter.start_time}s) 不能为负数",
135
+ )
136
+ )
137
+
138
+ if chapter.end_time > self.total_duration:
139
+ self.errors.append(
140
+ ValidationError(
141
+ chapter_index=i,
142
+ error_type="time_range",
143
+ message=(
144
+ f"章节 {i + 1} 结束时间 ({chapter.end_time}s) 超出视频总时长 ({self.total_duration}s)"
145
+ ),
146
+ )
147
+ )
148
+
149
+ def _check_time_gaps(self):
150
+ """检查时间间隙(警告)"""
151
+ # 检查第一个章节是否从 0 开始
152
+ if self.chapters and self.chapters[0].start_time > 0:
153
+ self.warnings.append(
154
+ ValidationError(
155
+ chapter_index=0,
156
+ error_type="time_gap",
157
+ message=(
158
+ f"第一个章节从 {self.chapters[0].start_time}s 开始,前面有 "
159
+ f"{self.chapters[0].start_time}s 的间隙"
160
+ ),
161
+ is_warning=True,
162
+ )
163
+ )
164
+
165
+ # 检查章节之间的间隙
166
+ for i in range(len(self.chapters) - 1):
167
+ current = self.chapters[i]
168
+ next_chapter = self.chapters[i + 1]
169
+
170
+ gap = next_chapter.start_time - current.end_time
171
+ if gap > 0:
172
+ self.warnings.append(
173
+ ValidationError(
174
+ chapter_index=i,
175
+ error_type="time_gap",
176
+ message=f"章节 {i + 1} 和章节 {i + 2} 之间有 {gap}s 的间隙",
177
+ is_warning=True,
178
+ )
179
+ )
180
+
181
+ # 检查最后一个章节是否到达视频结尾
182
+ if self.chapters and self.chapters[-1].end_time < self.total_duration:
183
+ gap = self.total_duration - self.chapters[-1].end_time
184
+ self.warnings.append(
185
+ ValidationError(
186
+ chapter_index=len(self.chapters) - 1,
187
+ error_type="time_gap",
188
+ message=(f"最后一个章节结束于 {self.chapters[-1].end_time}s,距离视频结尾还有 {gap}s"),
189
+ is_warning=True,
190
+ )
191
+ )
192
+
193
+ def _check_titles(self):
194
+ """检查标题"""
195
+ for i, chapter in enumerate(self.chapters):
196
+ if chapter.title and len(chapter.title.strip()) == 0:
197
+ self.errors.append(
198
+ ValidationError(chapter_index=i, error_type="empty_title", message=f"章节 {i + 1} 标题为空")
199
+ )
200
+
201
+ if chapter.title and len(chapter.title) > 100:
202
+ self.warnings.append(
203
+ ValidationError(
204
+ chapter_index=i,
205
+ error_type="long_title",
206
+ message=(f"章节 {i + 1} 标题过长 ({len(chapter.title)} 字符),建议不超过 100 字符"),
207
+ is_warning=True,
208
+ )
209
+ )
210
+
211
+ def _check_duration(self):
212
+ """检查章节时长(警告)"""
213
+ for i, chapter in enumerate(self.chapters):
214
+ duration = chapter.end_time - chapter.start_time
215
+
216
+ if duration < 5:
217
+ self.warnings.append(
218
+ ValidationError(
219
+ chapter_index=i,
220
+ error_type="short_duration",
221
+ message=f"章节 {i + 1} 时长过短 ({duration}s),建议至少 5 秒",
222
+ is_warning=True,
223
+ )
224
+ )
225
+
226
+ if duration > 600: # 10 分钟
227
+ self.warnings.append(
228
+ ValidationError(
229
+ chapter_index=i,
230
+ error_type="long_duration",
231
+ message=f"章节 {i + 1} 时长过长 ({duration}s),建议不超过 10 分钟",
232
+ is_warning=True,
233
+ )
234
+ )
chapterbar/cli.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """命令行界面"""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from chapterbar.chapter_extractor import extract_chapters_ai, extract_chapters_auto
9
+ from chapterbar.chapter_loader import ChapterLoader
10
+ from chapterbar.generator import generate_video
11
+ from chapterbar.interactive_editor import display_chapters_table
12
+ from chapterbar.parser import parse_srt
13
+
14
+ app = typer.Typer(help="Auto-Chapter-Bar - 视频章节进度条生成器")
15
+ console = Console()
16
+
17
+
18
+ @app.command()
19
+ def main(
20
+ srt_file: Path | None = typer.Argument(None, help="SRT 字幕文件路径(可选,使用 --chapters 时不需要)"),
21
+ duration: float | None = typer.Argument(None, help="视频总时长(秒),不提供则从 SRT 文件自动获取"),
22
+ output: Path = typer.Option("chapter_bar.mov", "--output", "-o", help="输出文件路径"),
23
+ width: int = typer.Option(1920, "--width", "-w", help="视频宽度"),
24
+ height: int = typer.Option(60, "--height", "-h", help="进度条高度"),
25
+ mode: str = typer.Option(
26
+ "ai",
27
+ "--mode",
28
+ "-m",
29
+ help="章节提取模式:auto(固定间隔)、ai(智能分段,默认)或 manual(手动配置)",
30
+ ),
31
+ interval: int = typer.Option(60, "--interval", "-i", help="自动分段间隔(秒),仅在 auto 模式下使用"),
32
+ api_key: str | None = typer.Option(
33
+ None,
34
+ "--api-key",
35
+ help="Moonshot API Key(AI 模式需要,也可通过环境变量 MOONSHOT_API_KEY 设置)",
36
+ ),
37
+ model: str = typer.Option("moonshot-v1-8k", "--model", help="AI 模型名称(默认 moonshot-v1-8k)"),
38
+ auto_confirm: bool = typer.Option(False, "--yes", "-y", help="自动确认所有提示(跳过时长确认和章节编辑)"),
39
+ chapters_file: Path | None = typer.Option(None, "--chapters", help="手动章节配置文件(YAML 格式)"),
40
+ save_chapters: Path | None = typer.Option(None, "--save-chapters", help="保存生成的章节配置到 YAML 文件"),
41
+ ):
42
+ """生成视频章节进度条"""
43
+
44
+ try:
45
+ # 1. 检查是否使用手动配置文件
46
+ if chapters_file:
47
+ console.print(f"[cyan]📄 正在加载章节配置文件: {chapters_file}[/cyan]")
48
+ try:
49
+ chapters, duration, warnings = ChapterLoader.load_from_yaml(str(chapters_file))
50
+ console.print(f"[green]✓ 配置加载成功,共 {len(chapters)} 个章节[/green]")
51
+ console.print(f"[cyan]📏 视频时长: {duration:.2f} 秒 ({duration / 60:.2f} 分钟)[/cyan]")
52
+
53
+ # 显示警告
54
+ if warnings:
55
+ console.print(f"\n[yellow]⚠️ 发现 {len(warnings)} 个警告:[/yellow]")
56
+ for warning in warnings:
57
+ console.print(f"[yellow] - {warning.message}[/yellow]")
58
+ console.print()
59
+
60
+ # 跳过 SRT 解析,直接到章节显示
61
+ entries = None
62
+
63
+ except (FileNotFoundError, ValueError) as e:
64
+ console.print(f"[red]✗ 错误: {e}[/red]")
65
+ raise typer.Exit(1) from e
66
+
67
+ # 2. 解析 SRT 文件(如果没有使用配置文件)
68
+ elif srt_file:
69
+ console.print(f"[cyan]正在解析 SRT 文件: {srt_file}[/cyan]")
70
+ entries = parse_srt(str(srt_file))
71
+ console.print(f"[green]✓ 解析完成,共 {len(entries)} 条字幕[/green]")
72
+
73
+ if not entries:
74
+ console.print("[red]✗ 错误: SRT 文件为空[/red]")
75
+ raise typer.Exit(1)
76
+ else:
77
+ console.print("[red]✗ 错误: 必须提供 SRT 文件或章节配置文件(--chapters)[/red]")
78
+ raise typer.Exit(1)
79
+
80
+ # 3. 处理视频时长(如果没有使用配置文件)
81
+ if not chapters_file:
82
+ srt_duration = entries[-1].end_time
83
+
84
+ if duration is None:
85
+ # 用户未提供时长,自动从 SRT 获取
86
+ duration = srt_duration
87
+ console.print(
88
+ f"[cyan]📏 从 SRT 文件自动获取视频时长: {duration:.2f} 秒 ({duration / 60:.2f} 分钟)[/cyan]"
89
+ )
90
+ else:
91
+ # 用户提供了时长,检查是否与 SRT 一致
92
+ console.print(f"[cyan]📏 用户指定视频时长: {duration:.2f} 秒 ({duration / 60:.2f} 分钟)[/cyan]")
93
+ console.print(f"[cyan]📏 SRT 文件实际时长: {srt_duration:.2f} 秒 ({srt_duration / 60:.2f} 分钟)[/cyan]")
94
+
95
+ # 如果差异超过 5 秒,显示警告
96
+ if abs(duration - srt_duration) > 5:
97
+ console.print("\n[yellow]⚠️ 警告: 指定时长与 SRT 实际时长不一致![/yellow]")
98
+ console.print(f"[yellow] 差异: {abs(duration - srt_duration):.2f} 秒[/yellow]\n")
99
+
100
+ if not auto_confirm:
101
+ # 询问用户选择
102
+ console.print("请选择使用哪个时长:")
103
+ console.print(f" [1] 使用 SRT 时长: {srt_duration:.2f} 秒 (推荐)")
104
+ console.print(f" [2] 使用指定时长: {duration:.2f} 秒")
105
+ console.print(" [3] 取消操作")
106
+
107
+ choice = typer.prompt("\n请输入选择 (1/2/3)", default="1")
108
+
109
+ if choice == "1":
110
+ duration = srt_duration
111
+ console.print(f"[green]✓ 使用 SRT 时长: {duration:.2f} 秒[/green]\n")
112
+ elif choice == "2":
113
+ console.print(f"[green]✓ 使用指定时长: {duration:.2f} 秒[/green]\n")
114
+ else:
115
+ console.print("[yellow]已取消操作[/yellow]")
116
+ raise typer.Exit(0)
117
+ else:
118
+ # 自动确认模式,使用 SRT 时长
119
+ duration = srt_duration
120
+ console.print(f"[green]✓ 自动使用 SRT 时长: {duration:.2f} 秒[/green]\n")
121
+
122
+ # 4. 提取章节(如果没有使用配置文件)
123
+ if chapters_file:
124
+ # 已经从配置文件加载了章节,跳过
125
+ pass
126
+ elif mode == "ai":
127
+ console.print(f"[cyan]🤖 正在使用 AI 智能分段(模型: {model})...[/cyan]")
128
+ console.print("[yellow]这可能需要几秒钟,请稍候...[/yellow]")
129
+ chapters = extract_chapters_ai(entries, duration, api_key, model)
130
+ console.print(f"[green]✓ AI 分段完成,共 {len(chapters)} 个章节[/green]\n")
131
+ else:
132
+ console.print(f"[cyan]正在提取章节(间隔: {interval}秒)...[/cyan]")
133
+ chapters = extract_chapters_auto(entries, interval, duration)
134
+ console.print(f"[green]✓ 提取完成,共 {len(chapters)} 个章节[/green]\n")
135
+
136
+ # 5. 保存章节配置(如果指定了 --save-chapters)
137
+ if save_chapters:
138
+ console.print(f"\n[cyan]💾 正在保存章节配置到: {save_chapters}[/cyan]")
139
+ try:
140
+ ChapterLoader.save_to_yaml(chapters, duration, str(save_chapters))
141
+ console.print("[green]✓ 章节配置已保存[/green]")
142
+ console.print(f"[cyan]💡 提示: 可以编辑 {save_chapters} 后使用 --chapters 参数重新生成[/cyan]\n")
143
+ except Exception as e:
144
+ console.print(f"[yellow]⚠️ 保存配置失败: {e}[/yellow]\n")
145
+
146
+ # 6. 显示章节列表
147
+ display_chapters_table(chapters)
148
+ console.print()
149
+
150
+ # 7. 交互式确认(仅在 AI 或 Auto 模式下,且未使用配置文件时)
151
+ # if not chapters_file and mode in ["ai", "auto"]:
152
+ # chapters = confirm_chapters(chapters, skip_confirm=auto_confirm)
153
+ # if chapters is None:
154
+ # # 用户选择退出
155
+ # raise typer.Exit(0)
156
+
157
+ # 8. 生成视频
158
+ console.print(f"[cyan]正在生成视频: {output}[/cyan]")
159
+ console.print("[yellow]这可能需要几分钟时间,请耐心等待...[/yellow]")
160
+
161
+ generate_video(
162
+ chapters=chapters,
163
+ output_path=str(output),
164
+ width=width,
165
+ height=height,
166
+ duration=duration,
167
+ )
168
+
169
+ console.print(f"[green]✓ 视频生成完成: {output}[/green]")
170
+ console.print("\n[bold]使用说明:[/bold]")
171
+ console.print("1. 在剪辑软件(PR/剪映/达芬奇)中打开原视频")
172
+ console.print("2. 将生成的章节条视频拖入最上层轨道")
173
+ console.print("3. 调整位置和大小,导出最终视频")
174
+
175
+ except Exception as e:
176
+ console.print(f"[red]✗ 错误: {e}[/red]")
177
+ raise typer.Exit(1) from e
178
+
179
+
180
+ if __name__ == "__main__":
181
+ app()
chapterbar/generator.py ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """视频生成器 - 并行优化版本
2
+ 性能优化:
3
+ 1. 字体缓存 - 避免重复加载字体
4
+ 2. 预计算章节布局 - 避免每帧重复计算
5
+ 3. 多进程并行 - 利用多核 CPU 加速帧生成
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from functools import partial
10
+ from multiprocessing import Pool, cpu_count
11
+
12
+ import numpy as np
13
+
14
+ # 使用 ImageSequenceClip 创建视频
15
+ from moviepy.video.io.ImageSequenceClip import ImageSequenceClip
16
+ from PIL import Image, ImageDraw, ImageFont
17
+
18
+ from chapterbar.chapter_extractor import Chapter
19
+ from chapterbar.logger import logger
20
+
21
+ # ============================================================================
22
+ # 全局字体缓存
23
+ # ============================================================================
24
+
25
+ _font_cache: dict[int, ImageFont.FreeTypeFont | None] = {}
26
+ _font_paths = [
27
+ "/System/Library/Fonts/STHeiti Light.ttc", # macOS 黑体
28
+ "/System/Library/Fonts/PingFang.ttc", # macOS 苹方
29
+ "/System/Library/Fonts/Hiragino Sans GB.ttc", # macOS
30
+ "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", # Linux
31
+ "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", # Linux
32
+ "C:\\Windows\\Fonts\\msyh.ttc", # Windows 微软雅黑
33
+ "C:\\Windows\\Fonts\\simhei.ttf", # Windows 黑体
34
+ ]
35
+
36
+
37
+ def get_cached_font(size: int) -> ImageFont.FreeTypeFont | None:
38
+ """获取缓存的字体
39
+
40
+ Args:
41
+ size: 字体大小
42
+
43
+ Returns:
44
+ 字体对象,如果加载失败则返回 None
45
+ """
46
+ if size not in _font_cache:
47
+ font = None
48
+ for font_path in _font_paths:
49
+ try:
50
+ font = ImageFont.truetype(font_path, size)
51
+ break
52
+ except Exception:
53
+ continue
54
+
55
+ # 如果所有字体都失败,尝试 Arial
56
+ if font is None:
57
+ try:
58
+ font = ImageFont.truetype("Arial.ttf", size)
59
+ except Exception:
60
+ font = ImageFont.load_default()
61
+
62
+ _font_cache[size] = font
63
+
64
+ return _font_cache[size]
65
+
66
+
67
+ # ============================================================================
68
+ # 预计算章节布局
69
+ # ============================================================================
70
+
71
+
72
+ @dataclass
73
+ class ChapterLayout:
74
+ """预计算的章节布局信息"""
75
+
76
+ x_offset: int
77
+ width: int
78
+ title: str
79
+ original_title: str # 原始标题(未截断)
80
+ font_size: int
81
+ text_x: int
82
+ text_y: int
83
+ text_width: int
84
+ text_height: int
85
+ should_draw_text: bool # 是否应该绘制文字
86
+
87
+
88
+ def precompute_chapter_layouts(
89
+ chapters: list[Chapter], width: int, height: int, duration: float
90
+ ) -> list[ChapterLayout]:
91
+ """预计算所有章节的布局信息
92
+
93
+ Args:
94
+ chapters: 章节列表
95
+ width: 视频宽度
96
+ height: 进度条高度
97
+ duration: 视频总时长
98
+
99
+ Returns:
100
+ 章节布局列表
101
+ """
102
+ layouts = []
103
+ x_offset = 0
104
+
105
+ for chapter in chapters:
106
+ # 计算章节宽度
107
+ chapter_duration = chapter.end_time - chapter.start_time
108
+ chapter_width = int((chapter_duration / duration) * width)
109
+
110
+ # 如果章节太窄,跳过文字
111
+ if chapter_width < 50:
112
+ layouts.append(
113
+ ChapterLayout(
114
+ x_offset=x_offset,
115
+ width=chapter_width,
116
+ title="",
117
+ original_title=chapter.title,
118
+ font_size=0,
119
+ text_x=0,
120
+ text_y=0,
121
+ text_width=0,
122
+ text_height=0,
123
+ should_draw_text=False,
124
+ )
125
+ )
126
+ x_offset += chapter_width
127
+ continue
128
+
129
+ # 计算文字布局
130
+ text = chapter.title
131
+ font_size = 20
132
+ font = get_cached_font(font_size)
133
+
134
+ # 创建临时 draw 对象用于测量
135
+ temp_img = Image.new("RGBA", (1, 1))
136
+ temp_draw = ImageDraw.Draw(temp_img)
137
+
138
+ bbox = temp_draw.textbbox((0, 0), text, font=font)
139
+ text_width = bbox[2] - bbox[0]
140
+ text_height = bbox[3] - bbox[1]
141
+
142
+ # 如果文字太长,逐步缩小字号
143
+ while text_width > chapter_width - 20 and font_size > 12:
144
+ font_size -= 2
145
+ font = get_cached_font(font_size)
146
+ bbox = temp_draw.textbbox((0, 0), text, font=font)
147
+ text_width = bbox[2] - bbox[0]
148
+ text_height = bbox[3] - bbox[1]
149
+
150
+ # 如果还是太长,截断文字
151
+ if text_width > chapter_width - 20:
152
+ while text and text_width > chapter_width - 20:
153
+ text = text[:-1]
154
+ bbox = temp_draw.textbbox((0, 0), text + "...", font=font)
155
+ text_width = bbox[2] - bbox[0]
156
+ if text:
157
+ text = text + "..."
158
+
159
+ # 计算文字位置(居中)
160
+ text_x = x_offset + (chapter_width - text_width) // 2
161
+ text_y = (height - text_height) // 2
162
+
163
+ layouts.append(
164
+ ChapterLayout(
165
+ x_offset=x_offset,
166
+ width=chapter_width,
167
+ title=text,
168
+ original_title=chapter.title,
169
+ font_size=font_size,
170
+ text_x=text_x,
171
+ text_y=text_y,
172
+ text_width=text_width,
173
+ text_height=text_height,
174
+ should_draw_text=bool(text),
175
+ )
176
+ )
177
+
178
+ x_offset += chapter_width
179
+
180
+ return layouts
181
+
182
+
183
+ # ============================================================================
184
+ # 优化的帧生成函数
185
+ # ============================================================================
186
+
187
+
188
+ def create_chapter_bar_frame_optimized(
189
+ t: float,
190
+ chapters: list[Chapter],
191
+ layouts: list[ChapterLayout],
192
+ width: int,
193
+ height: int,
194
+ duration: float,
195
+ ) -> np.ndarray:
196
+ """创建章节条的单帧图像(优化版本)
197
+
198
+ Args:
199
+ t: 当前时间(秒)
200
+ chapters: 章节列表
201
+ layouts: 预计算的章节布局
202
+ width: 视频宽度
203
+ height: 进度条高度
204
+ duration: 视频总时长
205
+
206
+ Returns:
207
+ np.ndarray: RGBA 图像数组
208
+ """
209
+ # 定义颜色方案
210
+ COLOR_UNWATCHED = (220, 220, 220) # 未播放:浅灰色
211
+ COLOR_WATCHED = (140, 140, 140) # 已播放:深灰色
212
+ COLOR_SEPARATOR = (100, 100, 100) # 分隔线:更深灰色
213
+
214
+ # 创建透明背景
215
+ img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
216
+ draw = ImageDraw.Draw(img)
217
+
218
+ # 绘制章节条
219
+ for i, (chapter, layout) in enumerate(zip(chapters, layouts, strict=True)):
220
+ # 判断章节状态并绘制
221
+ if chapter.end_time <= t:
222
+ # 完全播放完 → 深灰色
223
+ draw.rectangle(
224
+ [layout.x_offset, 0, layout.x_offset + layout.width, height],
225
+ fill=COLOR_WATCHED + (255,),
226
+ )
227
+ elif chapter.start_time <= t < chapter.end_time:
228
+ # 当前章节 → 分段绘制
229
+ chapter_duration = chapter.end_time - chapter.start_time
230
+ played_duration = t - chapter.start_time
231
+ played_width = int((played_duration / chapter_duration) * layout.width)
232
+
233
+ # 已播放部分:深灰色
234
+ if played_width > 0:
235
+ draw.rectangle(
236
+ [layout.x_offset, 0, layout.x_offset + played_width, height],
237
+ fill=COLOR_WATCHED + (255,),
238
+ )
239
+
240
+ # 未播放部分:浅灰色
241
+ if played_width < layout.width:
242
+ draw.rectangle(
243
+ [layout.x_offset + played_width, 0, layout.x_offset + layout.width, height],
244
+ fill=COLOR_UNWATCHED + (255,),
245
+ )
246
+ else:
247
+ # 未播放 → 浅灰色
248
+ draw.rectangle(
249
+ [layout.x_offset, 0, layout.x_offset + layout.width, height],
250
+ fill=COLOR_UNWATCHED + (255,),
251
+ )
252
+
253
+ # 绘制章节分隔线(除了第一个章节)
254
+ if i > 0:
255
+ draw.line(
256
+ [(layout.x_offset, 0), (layout.x_offset, height)],
257
+ fill=COLOR_SEPARATOR + (255,),
258
+ width=2,
259
+ )
260
+
261
+ # 绘制章节标题(使用预计算的布局)
262
+ if layout.should_draw_text:
263
+ font = get_cached_font(layout.font_size)
264
+
265
+ # 根据章节状态选择文字颜色
266
+ if chapter.end_time <= t:
267
+ # 已播放章节:深灰色背景 → 白色文字
268
+ text_color = (255, 255, 255, 255)
269
+ shadow_color = (0, 0, 0, 120)
270
+ elif chapter.start_time <= t < chapter.end_time:
271
+ # 当前章节:混合背景 → 白色文字
272
+ text_color = (255, 255, 255, 255)
273
+ shadow_color = (0, 0, 0, 120)
274
+ else:
275
+ # 未播放章节:浅灰色背景 → 深灰色文字
276
+ text_color = (80, 80, 80, 255)
277
+ shadow_color = (255, 255, 255, 120)
278
+
279
+ # 绘制文字阴影
280
+ draw.text((layout.text_x + 1, layout.text_y + 1), layout.title, fill=shadow_color, font=font)
281
+ # 绘制文字
282
+ draw.text((layout.text_x, layout.text_y), layout.title, fill=text_color, font=font)
283
+
284
+ # 绘制进度指针(白色竖线,4px 宽)
285
+ pointer_x = int((t / duration) * width)
286
+ # 绘制指针阴影
287
+ draw.rectangle([pointer_x - 3, 0, pointer_x + 3, height], fill=(0, 0, 0, 100))
288
+ # 绘制指针主体
289
+ draw.rectangle([pointer_x - 2, 0, pointer_x + 2, height], fill=(255, 255, 255, 255))
290
+
291
+ # 转换为 numpy 数组
292
+ return np.array(img)
293
+
294
+
295
+ # ============================================================================
296
+ # 并行帧生成
297
+ # ============================================================================
298
+
299
+
300
+ def generate_frame_batch(
301
+ frame_indices: list[int],
302
+ chapters: list[Chapter],
303
+ layouts: list[ChapterLayout],
304
+ width: int,
305
+ height: int,
306
+ duration: float,
307
+ fps: int,
308
+ ) -> list[tuple[int, np.ndarray]]:
309
+ """生成一批帧(用于并行处理)
310
+
311
+ Args:
312
+ frame_indices: 要生成的帧索引列表
313
+ chapters: 章节列表
314
+ layouts: 预计算的章节布局
315
+ width: 视频宽度
316
+ height: 进度条高度
317
+ duration: 视频总时长
318
+ fps: 帧率
319
+
320
+ Returns:
321
+ (帧索引, 帧数据) 的列表
322
+ """
323
+ frames = []
324
+ for frame_num in frame_indices:
325
+ t = frame_num / fps
326
+ frame = create_chapter_bar_frame_optimized(t, chapters, layouts, width, height, duration)
327
+ frames.append((frame_num, frame))
328
+ return frames
329
+
330
+
331
+ # ============================================================================
332
+ # 优化的视频生成函数(支持并行)
333
+ # ============================================================================
334
+
335
+
336
+ def generate_video(
337
+ chapters: list[Chapter],
338
+ output_path: str,
339
+ width: int = 1920,
340
+ height: int = 60,
341
+ duration: float = None,
342
+ fps: int = 30,
343
+ use_parallel: bool = True,
344
+ num_workers: int | None = None,
345
+ ) -> None:
346
+ """生成章节进度条视频(支持并行优化)
347
+
348
+ Args:
349
+ chapters: 章节列表
350
+ output_path: 输出文件路径
351
+ width: 视频宽度
352
+ height: 进度条高度
353
+ duration: 视频总时长(秒)
354
+ fps: 帧率
355
+ use_parallel: 是否使用并行处理(默认 True)
356
+ num_workers: 并行工作进程数(默认为 CPU 核心数)
357
+ """
358
+ if duration is None:
359
+ duration = chapters[-1].end_time if chapters else 10
360
+
361
+ # 预计算章节布局(只计算一次)
362
+ logger.info("正在预计算章节布局...")
363
+ layouts = precompute_chapter_layouts(chapters, width, height, duration)
364
+ logger.info(f"布局计算完成,共 {len(layouts)} 个章节")
365
+
366
+ total_frames = int(duration * fps)
367
+
368
+ # 根据帧数决定是否使用并行
369
+ # 帧数太少时,并行开销可能大于收益
370
+ if total_frames < 300:
371
+ use_parallel = False
372
+ logger.info(f"帧数较少 ({total_frames} 帧),使用串行生成")
373
+
374
+ if use_parallel:
375
+ # 并行生成帧
376
+ if num_workers is None:
377
+ num_workers = cpu_count()
378
+
379
+ logger.info(f"正在并行生成 {total_frames} 帧(使用 {num_workers} 个进程)...")
380
+
381
+ # 分批:将帧索引分配给各个进程
382
+ batch_size = (total_frames + num_workers - 1) // num_workers
383
+ frame_batches = []
384
+ for i in range(num_workers):
385
+ start_idx = i * batch_size
386
+ end_idx = min((i + 1) * batch_size, total_frames)
387
+ if start_idx < total_frames:
388
+ frame_batches.append(list(range(start_idx, end_idx)))
389
+
390
+ # 创建部分函数(固定参数)
391
+ batch_func = partial(
392
+ generate_frame_batch,
393
+ chapters=chapters,
394
+ layouts=layouts,
395
+ width=width,
396
+ height=height,
397
+ duration=duration,
398
+ fps=fps,
399
+ )
400
+
401
+ # 并行生成
402
+ with Pool(num_workers) as pool:
403
+ results = pool.map(batch_func, frame_batches)
404
+
405
+ # 合并结果并按帧索引排序
406
+ all_frames = []
407
+ for batch_result in results:
408
+ all_frames.extend(batch_result)
409
+
410
+ # 按帧索引排序
411
+ all_frames.sort(key=lambda x: x[0])
412
+ frames = [frame for _, frame in all_frames]
413
+
414
+ logger.info("并行帧生成完成")
415
+ else:
416
+ # 串行生成帧(原有逻辑)
417
+ logger.info(f"正在生成 {total_frames} 帧...")
418
+ frames = []
419
+
420
+ last_progress = -1
421
+ for frame_num in range(total_frames):
422
+ t = frame_num / fps
423
+ frame = create_chapter_bar_frame_optimized(t, chapters, layouts, width, height, duration)
424
+ frames.append(frame)
425
+
426
+ # 显示进度(每 5% 显示一次)
427
+ progress = int((frame_num + 1) / total_frames * 100)
428
+ if progress % 5 == 0 and progress != last_progress:
429
+ logger.info(f"进度: {frame_num + 1}/{total_frames} ({progress}%)")
430
+ last_progress = progress
431
+
432
+ logger.info("帧生成完成")
433
+
434
+ clip = ImageSequenceClip(frames, fps=fps)
435
+
436
+ # 输出视频
437
+ logger.info("正在编码视频...")
438
+ try:
439
+ clip.write_videofile(
440
+ output_path,
441
+ fps=fps,
442
+ codec="qtrle", # QuickTime Animation codec with alpha
443
+ audio=False,
444
+ logger=None,
445
+ ffmpeg_params=["-pix_fmt", "argb"],
446
+ preset="ultrafast", # 加快编码速度
447
+ )
448
+ except Exception as e:
449
+ # 如果 qtrle 失败,尝试使用 png
450
+ logger.warning(f"qtrle 编码失败,使用 png 编码: {e}")
451
+ clip.write_videofile(output_path, fps=fps, codec="png", audio=False, logger=None)
452
+
453
+ logger.info("视频生成完成")
454
+
455
+
456
+ # ============================================================================
457
+ # 向后兼容:保留原函数名
458
+ # ============================================================================
459
+
460
+
461
+ def create_chapter_bar_frame(t: float, chapters: list[Chapter], width: int, height: int, duration: float) -> np.ndarray:
462
+ """创建章节条的单帧图像(向后兼容的包装函数)
463
+
464
+ 注意:此函数为向后兼容保留,性能较差。
465
+ 建议使用 create_chapter_bar_frame_optimized 配合预计算布局。
466
+ """
467
+ # 每次调用都重新计算布局(性能较差)
468
+ layouts = precompute_chapter_layouts(chapters, width, height, duration)
469
+ return create_chapter_bar_frame_optimized(t, chapters, layouts, width, height, duration)
chapterbar/interactive_editor.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """交互式章节编辑器"""
2
+
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ from chapterbar.chapter_extractor import BASE_COLOR, Chapter
7
+ from chapterbar.chapter_validator import ChapterValidator
8
+
9
+ console = Console()
10
+
11
+
12
+ def format_time(seconds: float) -> str:
13
+ """格式化时间为 mm:ss"""
14
+ minutes = int(seconds // 60)
15
+ secs = int(seconds % 60)
16
+ return f"{minutes:02d}:{secs:02d}"
17
+
18
+
19
+ def parse_time_input(time_str: str) -> float | None:
20
+ """解析时间输入(支持 mm:ss 或秒数)"""
21
+ time_str = time_str.strip()
22
+ if not time_str:
23
+ return None
24
+
25
+ try:
26
+ # 尝试解析为秒数
27
+ return float(time_str)
28
+ except ValueError:
29
+ pass
30
+
31
+ # 尝试解析为 mm:ss 格式
32
+ if ":" in time_str:
33
+ try:
34
+ parts = time_str.split(":")
35
+ if len(parts) == 2:
36
+ minutes = int(parts[0])
37
+ seconds = int(parts[1])
38
+ return minutes * 60 + seconds
39
+ except ValueError:
40
+ pass
41
+
42
+ return None
43
+
44
+
45
+ def display_chapters_table(chapters: list[Chapter], title: str = "章节列表"):
46
+ """显示章节列表"""
47
+ table = Table(title=title)
48
+ table.add_column("序号", style="cyan")
49
+ table.add_column("开始时间", style="magenta")
50
+ table.add_column("结束时间", style="magenta")
51
+ table.add_column("标题", style="green")
52
+
53
+ for i, chapter in enumerate(chapters, 1):
54
+ table.add_row(str(i), format_time(chapter.start_time), format_time(chapter.end_time), chapter.title)
55
+
56
+ console.print(table)
57
+
58
+
59
+ def edit_chapter(chapters: list[Chapter], index: int, duration: float) -> bool:
60
+ """编辑单个章节"""
61
+ if index < 0 or index >= len(chapters):
62
+ console.print("[red]✗ 无效的章节序号[/red]")
63
+ return False
64
+
65
+ chapter = chapters[index]
66
+ console.print(f"\n[cyan]编辑章节 {index + 1}:[/cyan]")
67
+ console.print(f"当前: {format_time(chapter.start_time)} - {format_time(chapter.end_time)} | {chapter.title}")
68
+ console.print()
69
+
70
+ # 编辑开始时间
71
+ start_input = input(f"开始时间 (mm:ss 或秒数,留空保持 {format_time(chapter.start_time)}): ").strip()
72
+ if start_input:
73
+ new_start = parse_time_input(start_input)
74
+ if new_start is None:
75
+ console.print("[red]✗ 无效的时间格式[/red]")
76
+ return False
77
+ chapter.start_time = new_start
78
+
79
+ # 编辑结束时间
80
+ end_input = input(f"结束时间 (mm:ss 或秒数,留空保持 {format_time(chapter.end_time)}): ").strip()
81
+ if end_input:
82
+ new_end = parse_time_input(end_input)
83
+ if new_end is None:
84
+ console.print("[red]✗ 无效的时间格式[/red]")
85
+ return False
86
+ chapter.end_time = new_end
87
+
88
+ # 编辑标题
89
+ title_input = input(f"标题 (留空保持 '{chapter.title}'): ").strip()
90
+ if title_input:
91
+ chapter.title = title_input
92
+
93
+ console.print(f"[green]✓ 章节 {index + 1} 已更新[/green]\n")
94
+ return True
95
+
96
+
97
+ def add_chapter(chapters: list[Chapter], duration: float) -> bool:
98
+ """添加新章节"""
99
+ console.print("\n[cyan]添加新章节:[/cyan]")
100
+
101
+ # 输入开始时间
102
+ start_input = input("开始时间 (mm:ss 或秒数): ").strip()
103
+ start_time = parse_time_input(start_input)
104
+ if start_time is None:
105
+ console.print("[red]✗ 无效的时间格式[/red]")
106
+ return False
107
+
108
+ # 输入结束时间
109
+ end_input = input("结束时间 (mm:ss 或秒数): ").strip()
110
+ end_time = parse_time_input(end_input)
111
+ if end_time is None:
112
+ console.print("[red]✗ 无效的时间格式[/red]")
113
+ return False
114
+
115
+ # 输入标题
116
+ title = input("标题: ").strip()
117
+ if not title:
118
+ console.print("[red]✗ 标题不能为空[/red]")
119
+ return False
120
+
121
+ # 创建新章节
122
+ new_chapter = Chapter(title=title, start_time=start_time, end_time=end_time, color=BASE_COLOR)
123
+
124
+ # 插入到合适的位置(按开始时间排序)
125
+ insert_pos = len(chapters)
126
+ for i, ch in enumerate(chapters):
127
+ if new_chapter.start_time < ch.start_time:
128
+ insert_pos = i
129
+ break
130
+
131
+ chapters.insert(insert_pos, new_chapter)
132
+ console.print(f"[green]✓ 章节已添加到位置 {insert_pos + 1}[/green]\n")
133
+ return True
134
+
135
+
136
+ def delete_chapter(chapters: list[Chapter], index: int) -> bool:
137
+ """删除章节"""
138
+ if index < 0 or index >= len(chapters):
139
+ console.print("[red]✗ 无效的章节序号[/red]")
140
+ return False
141
+
142
+ removed = chapters.pop(index)
143
+ console.print(f"[green]✓ 已删除章节 {index + 1}: {removed.title}[/green]\n")
144
+ return True
145
+
146
+
147
+ def interactive_edit_chapters(chapters: list[Chapter], duration: float) -> list[Chapter] | None:
148
+ """交互式编辑章节
149
+
150
+ 返回:
151
+ 编辑后的章节列表,如果用户取消则返回 None
152
+ """
153
+ # 创建副本,避免修改原始数据
154
+ chapters = [Chapter(ch.title, ch.start_time, ch.end_time, ch.color) for ch in chapters]
155
+
156
+ console.print("\n[bold cyan]📝 编辑模式[/bold cyan]")
157
+ console.print("\n可用命令:")
158
+ console.print(" [数字] - 编辑章节 (如: 1)")
159
+ console.print(" [d数字] - 删除章节 (如: d2)")
160
+ console.print(" [a] - 添加章节")
161
+ console.print(" [l] - 显示章节列表")
162
+ console.print(" [done] - 完成编辑并继续")
163
+ console.print(" [cancel] - 取消编辑\n")
164
+
165
+ while True:
166
+ cmd = input("> ").strip().lower()
167
+
168
+ if cmd == "done":
169
+ # 验证章节
170
+ console.print("\n[cyan]正在验证章节...[/cyan]")
171
+ errors = ChapterValidator.validate_chapters(chapters, duration)
172
+
173
+ if errors:
174
+ console.print("[red]✗ 验证失败:[/red]")
175
+ for error in errors:
176
+ console.print(f"[red] - {error.message}[/red]")
177
+ console.print("\n[yellow]请修正错误后再试,或输入 'cancel' 取消编辑[/yellow]\n")
178
+ continue
179
+
180
+ console.print("[green]✓ 验证通过[/green]\n")
181
+ return chapters
182
+
183
+ elif cmd == "cancel":
184
+ console.print("[yellow]已取消编辑[/yellow]\n")
185
+ return None
186
+
187
+ elif cmd == "l":
188
+ display_chapters_table(chapters)
189
+ console.print()
190
+
191
+ elif cmd == "a":
192
+ if add_chapter(chapters, duration):
193
+ display_chapters_table(chapters)
194
+ console.print()
195
+
196
+ elif cmd.startswith("d") and len(cmd) > 1:
197
+ try:
198
+ index = int(cmd[1:]) - 1
199
+ if delete_chapter(chapters, index):
200
+ display_chapters_table(chapters)
201
+ console.print()
202
+ except ValueError:
203
+ console.print("[red]✗ 无效的命令格式,使用 'd数字' 删除章节 (如: d2)[/red]\n")
204
+
205
+ elif cmd.isdigit():
206
+ index = int(cmd) - 1
207
+ if edit_chapter(chapters, index, duration):
208
+ display_chapters_table(chapters)
209
+ console.print()
210
+
211
+ elif cmd:
212
+ console.print("[red]✗ 无效的命令,输入 'l' 查看帮助[/red]\n")
213
+
214
+
215
+ def confirm_chapters(chapters: list[Chapter], skip_confirm: bool = False) -> list[Chapter] | None:
216
+ """确认章节配置
217
+
218
+ 参数:
219
+ chapters: 章节列表
220
+ skip_confirm: 是否跳过确认(--yes 参数)
221
+
222
+ 返回:
223
+ 确认或编辑后的章节列表,如果用户退出则返回 None
224
+ """
225
+ if skip_confirm:
226
+ return chapters
227
+
228
+ console.print("\n[bold]请选择操作:[/bold]")
229
+ console.print(" [y] 确认并生成视频")
230
+ console.print(" [e] 编辑章节")
231
+ console.print(" [q] 退出\n")
232
+
233
+ while True:
234
+ choice = input("> ").strip().lower()
235
+
236
+ if choice == "y":
237
+ console.print("[green]✓ 已确认,开始生成视频...[/green]\n")
238
+ return chapters
239
+
240
+ elif choice == "e":
241
+ # 获取视频时长(从最后一个章节)
242
+ duration = chapters[-1].end_time if chapters else 0
243
+ edited_chapters = interactive_edit_chapters(chapters, duration)
244
+
245
+ if edited_chapters is None:
246
+ # 用户取消编辑,回到确认界面
247
+ console.print("\n[bold]请选择操作:[/bold]")
248
+ console.print(" [y] 确认并生成视频")
249
+ console.print(" [e] 编辑章节")
250
+ console.print(" [q] 退出\n")
251
+ continue
252
+
253
+ return edited_chapters
254
+
255
+ elif choice == "q":
256
+ console.print("[yellow]已退出[/yellow]")
257
+ return None
258
+
259
+ else:
260
+ console.print("[red]✗ 无效的选择,请输入 y/e/q[/red]\n")
chapterbar/logger.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """日志配置模块
2
+
3
+ 使用 loguru 提供统一的日志记录功能。
4
+ """
5
+
6
+ import sys
7
+
8
+ from loguru import logger
9
+
10
+ # 移除默认的 handler
11
+ logger.remove()
12
+
13
+ # 添加自定义 handler
14
+ # 格式:时间 | 级别 | 文件:行号 | 消息
15
+ logger.add(
16
+ sys.stderr,
17
+ format=(
18
+ "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
19
+ "<level>{level: <8}</level> | "
20
+ "<cyan>{name}</cyan>:<cyan>{line}</cyan> | "
21
+ "<level>{message}</level>"
22
+ ),
23
+ level="INFO",
24
+ colorize=True,
25
+ )
26
+
27
+ # 导出 logger 供其他模块使用
28
+ __all__ = ["logger"]
chapterbar/parser.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SRT 字幕文件解析器"""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class SubtitleEntry:
9
+ """字幕条目"""
10
+
11
+ index: int
12
+ start_time: float # 秒
13
+ end_time: float # 秒
14
+ text: str
15
+
16
+
17
+ def parse_timestamp(timestamp: str) -> float:
18
+ """将 HH:MM:SS,mmm 格式转换为秒数
19
+
20
+ Args:
21
+ timestamp: 时间戳字符串,如 "00:01:23,456"
22
+
23
+ Returns:
24
+ float: 秒数
25
+ """
26
+ # 匹配格式:HH:MM:SS,mmm
27
+ match = re.match(r"(\d{2}):(\d{2}):(\d{2}),(\d{3})", timestamp)
28
+ if not match:
29
+ raise ValueError(f"无效的时间戳格式: {timestamp}")
30
+
31
+ hours, minutes, seconds, milliseconds = map(int, match.groups())
32
+ total_seconds = hours * 3600 + minutes * 60 + seconds + milliseconds / 1000
33
+ return total_seconds
34
+
35
+
36
+ def parse_srt(file_path: str) -> list[SubtitleEntry]:
37
+ """解析 SRT 字幕文件
38
+
39
+ Args:
40
+ file_path: SRT 文件路径
41
+
42
+ Returns:
43
+ List[SubtitleEntry]: 字幕条目列表
44
+ """
45
+ with open(file_path, encoding="utf-8") as f:
46
+ content = f.read()
47
+
48
+ entries = []
49
+ # 按空行分割字幕块
50
+ blocks = content.strip().split("\n\n")
51
+
52
+ for block in blocks:
53
+ lines = block.strip().split("\n")
54
+ if len(lines) < 3:
55
+ continue
56
+
57
+ # 第一行是序号
58
+ try:
59
+ index = int(lines[0])
60
+ except ValueError:
61
+ continue
62
+
63
+ # 第二行是时间戳
64
+ timestamp_line = lines[1]
65
+ match = re.match(r"(.+?)\s*-->\s*(.+)", timestamp_line)
66
+ if not match:
67
+ continue
68
+
69
+ start_str, end_str = match.groups()
70
+ start_time = parse_timestamp(start_str.strip())
71
+ end_time = parse_timestamp(end_str.strip())
72
+
73
+ # 剩余行是文本内容
74
+ text = " ".join(lines[2:])
75
+
76
+ entries.append(SubtitleEntry(index=index, start_time=start_time, end_time=end_time, text=text))
77
+
78
+ return entries
requirements.txt ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file was autogenerated by uv via the following command:
2
+ # uv export --no-hashes --no-dev -o requirements.txt
3
+ aiofiles==24.1.0
4
+ # via gradio
5
+ annotated-doc==0.0.4
6
+ # via fastapi
7
+ annotated-types==0.7.0
8
+ # via pydantic
9
+ anyio==4.11.0
10
+ # via
11
+ # gradio
12
+ # httpx
13
+ # openai
14
+ # starlette
15
+ # via gradio
16
+ brotli==1.2.0
17
+ # via gradio
18
+ certifi==2025.11.12
19
+ # via
20
+ # httpcore
21
+ # httpx
22
+ click==8.3.0
23
+ # via
24
+ # typer
25
+ # typer-slim
26
+ # uvicorn
27
+ colorama==0.4.6 ; sys_platform == 'win32'
28
+ # via
29
+ # click
30
+ # loguru
31
+ # tqdm
32
+ decorator==5.2.1
33
+ # via moviepy
34
+ distro==1.9.0
35
+ # via openai
36
+ fastapi==0.121.2
37
+ # via gradio
38
+ ffmpy==1.0.0
39
+ # via gradio
40
+ filelock==3.20.0
41
+ # via huggingface-hub
42
+ fsspec==2025.10.0
43
+ # via
44
+ # gradio-client
45
+ # huggingface-hub
46
+ gradio==5.49.1
47
+ # via auto-chapter-bar
48
+ gradio-client==1.13.3
49
+ # via gradio
50
+ groovy==0.1.2
51
+ # via gradio
52
+ h11==0.16.0
53
+ # via
54
+ # httpcore
55
+ # uvicorn
56
+ hf-xet==1.2.0 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
57
+ # via huggingface-hub
58
+ httpcore==1.0.9
59
+ # via httpx
60
+ httpx==0.28.1
61
+ # via
62
+ # gradio
63
+ # gradio-client
64
+ # huggingface-hub
65
+ # openai
66
+ # safehttpx
67
+ huggingface-hub==1.1.4
68
+ # via
69
+ # gradio
70
+ # gradio-client
71
+ idna==3.11
72
+ # via
73
+ # anyio
74
+ # httpx
75
+ imageio==2.37.2
76
+ # via moviepy
77
+ imageio-ffmpeg==0.6.0
78
+ # via moviepy
79
+ jinja2==3.1.6
80
+ # via gradio
81
+ jiter==0.12.0
82
+ # via openai
83
+ loguru==0.7.3
84
+ # via auto-chapter-bar
85
+ markdown-it-py==4.0.0
86
+ # via rich
87
+ markupsafe==3.0.3
88
+ # via
89
+ # gradio
90
+ # jinja2
91
+ mdurl==0.1.2
92
+ # via markdown-it-py
93
+ moviepy==2.2.1
94
+ # via auto-chapter-bar
95
+ numpy==2.3.4
96
+ # via
97
+ # gradio
98
+ # imageio
99
+ # moviepy
100
+ # pandas
101
+ openai==2.8.0
102
+ # via auto-chapter-bar
103
+ orjson==3.11.4
104
+ # via gradio
105
+ packaging==25.0
106
+ # via
107
+ # gradio
108
+ # gradio-client
109
+ # huggingface-hub
110
+ pandas==2.3.3
111
+ # via gradio
112
+ pillow==11.3.0
113
+ # via
114
+ # auto-chapter-bar
115
+ # gradio
116
+ # imageio
117
+ # moviepy
118
+ proglog==0.1.12
119
+ # via moviepy
120
+ pydantic==2.11.10
121
+ # via
122
+ # fastapi
123
+ # gradio
124
+ # openai
125
+ pydantic-core==2.33.2
126
+ # via pydantic
127
+ pydub==0.25.1
128
+ # via gradio
129
+ pygments==2.19.2
130
+ # via rich
131
+ python-dateutil==2.9.0.post0
132
+ # via pandas
133
+ python-dotenv==1.2.1
134
+ # via moviepy
135
+ python-multipart==0.0.20
136
+ # via gradio
137
+ pytz==2025.2
138
+ # via pandas
139
+ pyyaml==6.0.3
140
+ # via
141
+ # auto-chapter-bar
142
+ # gradio
143
+ # huggingface-hub
144
+ rich==14.2.0
145
+ # via
146
+ # auto-chapter-bar
147
+ # typer
148
+ ruff==0.14.5
149
+ # via gradio
150
+ safehttpx==0.1.7
151
+ # via gradio
152
+ semantic-version==2.10.0
153
+ # via gradio
154
+ shellingham==1.5.4
155
+ # via
156
+ # huggingface-hub
157
+ # typer
158
+ six==1.17.0
159
+ # via python-dateutil
160
+ sniffio==1.3.1
161
+ # via
162
+ # anyio
163
+ # openai
164
+ starlette==0.49.3
165
+ # via
166
+ # fastapi
167
+ # gradio
168
+ tomlkit==0.13.3
169
+ # via gradio
170
+ tqdm==4.67.1
171
+ # via
172
+ # huggingface-hub
173
+ # openai
174
+ # proglog
175
+ typer==0.20.0
176
+ # via
177
+ # auto-chapter-bar
178
+ # gradio
179
+ typer-slim==0.20.0
180
+ # via huggingface-hub
181
+ typing-extensions==4.15.0
182
+ # via
183
+ # fastapi
184
+ # gradio
185
+ # gradio-client
186
+ # huggingface-hub
187
+ # openai
188
+ # pydantic
189
+ # pydantic-core
190
+ # typer
191
+ # typer-slim
192
+ # typing-inspection
193
+ typing-inspection==0.4.2
194
+ # via pydantic
195
+ tzdata==2025.2
196
+ # via pandas
197
+ uvicorn==0.38.0
198
+ # via gradio
199
+ websockets==15.0.1
200
+ # via gradio-client
201
+ win32-setctime==1.2.0 ; sys_platform == 'win32'
202
+ # via loguru