Spaces:
Sleeping
Sleeping
github-actions[bot]
commited on
Commit
·
db4f540
0
Parent(s):
Deploy from GitHub Actions - 2025-11-15 11:44:56
Browse files- .gitignore +7 -0
- LICENSE +201 -0
- README.md +50 -0
- app.py +407 -0
- chapterbar/__init__.py +3 -0
- chapterbar/chapter_extractor.py +198 -0
- chapterbar/chapter_loader.py +141 -0
- chapterbar/chapter_validator.py +234 -0
- chapterbar/cli.py +181 -0
- chapterbar/generator.py +469 -0
- chapterbar/interactive_editor.py +260 -0
- chapterbar/logger.py +28 -0
- chapterbar/parser.py +78 -0
- requirements.txt +202 -0
.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
|