@lmliheng/lesson-video 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.
package/README.md CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # lesson-video
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Narrated **vertical lesson videos** (1080×1920, Douyin / Reels ready) from a written script — plus the cover art and the publishing copy that goes with them.
4
+
5
+ The whole thing rests on one idea: **the picture is a pure function of time `t`**. You write what is said, edge-tts speaks it and hands back word-level timings, and every frame is then *computed* from that timeline. Voice, subtitles and slides line up by construction, so there is nothing to edit afterwards.
6
+
7
+ ```
8
+ script.json ──tts.py──▶ narration.wav + lesson.json ──render.mjs──▶ 30fps JPEG frames ──build.py──▶ mp4 + srt
9
+ covers.json ──cover.mjs──▶ 1080×1920 video cover + 1080×1440 profile cover
10
+ ```
11
+
12
+ ## Why it exists
13
+
14
+ - **Safe areas for real phones.** App buttons and status bars cover the edges of a portrait screen, so the layout keeps the sides and the top empty by design: content lives inside 745 × 1170 px, and the subtitle band starts at y = 1470.
15
+ - **A pre-render self-check.** `render.mjs --check` fails *before* spending minutes on frames: content past the subtitle safe line, code lines clipped by the card, and titles left with a one-character orphan line.
16
+ - **No editing software, no GPU.** Headless Chrome + `imageio-ffmpeg`; a three-minute episode renders in about five minutes on CPU, and re-rendering after a script fix is one command.
17
+ - **No baked-in branding.** Watermark, brand mark, CTA and every caption come from the data files — the templates ship with nothing of anyone's hard-coded.
18
+
19
+ ## What's inside
20
+
21
+ | Path | What it is |
22
+ | --- | --- |
23
+ | `skills/lesson-video/SKILL.md` | The skill: script schema, the three commands, the layout rules, the pitfalls |
24
+ | `skills/lesson-video/kit/` | The toolkit — `tts.py`, `render.mjs`, `build.py`, `deck.html`, `cover.mjs`, `cover.html`; copy it into a project as `lib/` |
25
+ | `skills/lesson-video/example/` | A skeleton `script.json` (four scene types) and `covers.json` |
26
+
27
+ ## Requirements
28
+
29
+ | Need | Install |
30
+ | --- | --- |
31
+ | Python packages | `pip install edge-tts imageio-ffmpeg` (the ffmpeg binary comes with the second one) |
32
+ | Playwright core | any usable `playwright-core`; point `PLAYWRIGHT_MODULE` at it |
33
+ | A browser | any Chrome / Chromium; point `CHROME_PATH` at it |
34
+
35
+ ## Quick start
36
+
37
+ ```bash
38
+ cp -r <skill>/kit/* <project>/lib/
39
+ mkdir -p <project>/lessons/ep01
40
+
41
+ # 1. write <project>/lessons/ep01/script.json (see the skill for the schema)
42
+
43
+ PYTHONUTF8=1 python lib/tts.py lessons/ep01
44
+ node lib/render.mjs --dir lessons/ep01 --check # 10s: safe area, clipped code, orphan titles
45
+ PLAYWRIGHT_MODULE=… CHROME_PATH=… node lib/render.mjs --dir lessons/ep01
46
+ PYTHONUTF8=1 python lib/build.py lessons/ep01 # out/ep01.mp4 + out/ep01.srt
47
+ ```
48
+
49
+ ## License
50
+
51
+ Apache-2.0
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Lesson video">
2
+ <rect x="20" y="5" width="24" height="54" rx="6" fill="#2f6fed"/>
3
+ <rect x="24.5" y="10" width="15" height="44" rx="3.5" fill="#0b1220"/>
4
+ <path d="M30 25.5v13l10-6.5z" fill="#eaf1ff"/>
5
+ <rect x="27.5" y="43" width="9" height="2.6" rx="1.3" fill="#7aa7f7"/>
6
+ <rect x="27.5" y="47.5" width="6" height="2.6" rx="1.3" fill="#5f8ff0"/>
7
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@lmliheng/lesson-video",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.1",
4
+ "description": "Narrated vertical lesson videos (1080x1920) from a script: an HTML slide deck driven by time, edge-tts voice-over with word-level timing, Playwright frame capture, ffmpeg mux, cover art and publishing copy.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/lmliheng/Adelie.git",
9
+ "directory": "plugins/lesson-video"
10
+ },
11
+ "keywords": [
12
+ "adelie-plugin",
13
+ "penguin-harness",
14
+ "video",
15
+ "1080x1920",
16
+ "vertical",
17
+ "tts",
18
+ "edge-tts",
19
+ "playwright",
20
+ "ffmpeg",
21
+ "slides"
22
+ ],
23
+ "files": [
24
+ "plugin.json",
25
+ "icon.svg",
26
+ "README.md",
27
+ "skills",
28
+ "LICENSE"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
6
33
  }
package/plugin.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "description": "Produce a narrated vertical lesson video (1080x1920) and its cover art from a written script: an HTML slide deck driven purely by time, edge-tts voice-over with word-level timing, Playwright frame capture, and an ffmpeg mux with edge safe areas left empty for mobile app buttons — plus a pre-render self-check for text overflow, and publishing copy with at most five tags.",
3
+ "description_zh": "把写好的讲稿做成竖屏教学视频(1080×1920)与配套封面:HTML 课件按时间轴定格、edge-tts 逐词对齐配音、Playwright 逐帧截图、ffmpeg 合成,两侧与上部按手机 App 按钮留出安全区,渲染前先自检越界与裁切,并给出标签不超过五个的发布文案。",
4
+ "short_description": "Narrated 1080x1920 lesson video from a script: HTML deck + edge-tts + ffmpeg.",
5
+ "short_description_zh": "讲稿一键成竖屏教学视频(1080×1920):HTML 课件 + edge-tts + ffmpeg,含封面与发布文案。",
6
+ "version": "2026.10.06.1",
7
+ "category": "office-productivity",
8
+ "preinstall": false
9
+ }
@@ -0,0 +1,172 @@
1
+ ---
2
+ name: lesson-video
3
+ description: Turn a narration script into a narrated vertical lesson video (1080x1920, Douyin/Reels ready) plus matching cover art — an HTML slide deck driven purely by time, edge-tts voice-over with word-level timing, Playwright frame capture, and an ffmpeg mux with burned-out safe areas. Use whenever the task is to produce a short portrait teaching or explainer video, a narrated slide video, or its cover and publishing copy, from a written script or from source material such as a notebook, dataset or paper.
4
+ ---
5
+
6
+ # 竖屏教学视频:讲稿 → 配音 → 逐帧渲染 → 成片 + 封面
7
+
8
+ ## Before you start
9
+
10
+ 如果消息只是点名这个技能(比如「用 lesson-video」)而没有具体要讲的内容,先问清楚讲什么、给谁看、
11
+ 一集多长——**先有话,再有画面**,没有讲稿就没有可渲染的东西。有内容时先自检三样东西在不在:Python
12
+ 包(`edge-tts`、`imageio-ffmpeg`)、一个可用的 `playwright-core`、一个 Chrome/Chromium 可执行文件;
13
+ 缺哪个按第一节的表装哪个,别急着渲染。渲染是最后一步,不是第一步:`render.mjs --check` 先过一遍,
14
+ 再花那几分钟出帧。
15
+
16
+ ## 结论先行
17
+
18
+ 一句话:**画面是时间 t 的纯函数**。先用话把内容说清楚,把每一句话写成脚本,配音合成后拿到逐词时间,画面按这个时间去定格 —— 于是配音、字幕、画面天然咬合,不需要剪辑。
19
+
20
+ | 你要的东西 | 用哪一步 | 产物 |
21
+ | --- | --- | --- |
22
+ | 一份讲稿 | 手写 `lessons/<集>/script.json` | 分镜 + 旁白 + 出现时机(`cue`) |
23
+ | 配音 + 时间轴 | `python lib/tts.py lessons/<集>` | `audio/narration.wav`、`lesson.json` |
24
+ | 逐帧画面 | `node lib/render.mjs --dir lessons/<集>` | `frames/f_00000.jpg…` (30fps) |
25
+ | 成片 + 字幕 | `python lib/build.py lessons/<集>` | `out/<id>.mp4`、`<id>.srt` |
26
+ | 封面 | `node lib/cover.mjs --data covers/covers.json --out covers/out --sizes 1920,1440` | 1080×1920 与 1080×1440 两张 PNG |
27
+
28
+ 写成一条链(GPU 无关,纯 CPU,3 分钟的片子约 5 分钟出片):
29
+
30
+ ```bash
31
+ python lib/tts.py lessons/ep01 && \
32
+ node lib/render.mjs --dir lessons/ep01 && \
33
+ python lib/build.py lessons/ep01
34
+ ```
35
+
36
+ **动手前先跑自检**(10 秒,不出帧):
37
+
38
+ ```bash
39
+ node lib/render.mjs --dir lessons/ep01 --check
40
+ ```
41
+
42
+ 它报三件事:内容有没有越过 y=1470 的字幕安全线、代码行有没有被卡片裁掉、标题折行有没有剩下孤字。三项全过再花几分钟渲染。
43
+
44
+ ## 一、把工具包放进项目
45
+
46
+ 技能目录里的 `kit/` 就是全部工具,复制到项目下当 `lib/` 用:
47
+
48
+ ```bash
49
+ mkdir -p <项目>/lessons/<集名> <项目>/covers
50
+ cp -r <技能目录>/kit/* <项目>/lib/
51
+ ```
52
+
53
+ `kit/` 六个文件,各管一件事:
54
+
55
+ | 文件 | 作用 |
56
+ | --- | --- |
57
+ | `tts.py` | edge-tts 逐幕配音,拿逐词边界,写出 `lesson.json`(时间轴 + 字幕)+ `narration.wav` |
58
+ | `render.mjs` | Playwright 逐帧截图;`--check` 只自检;`--scale 0.5` 低清预览 |
59
+ | `build.py` | ffmpeg 合成 H.264/AAC、响度归一、`+faststart`,顺带写 SRT |
60
+ | `deck.html` | 课件本体,一种 1080×1920 版式,幕类型见第三节 |
61
+ | `cover.mjs` / `cover.html` | 封面图(9:16 视频首图 + 3:4 主页封面) |
62
+
63
+ **依赖**(缺哪个装哪个):
64
+
65
+ | 依赖 | 装法 | 备注 |
66
+ | --- | --- | --- |
67
+ | Python 包 | `pip install edge-tts imageio-ffmpeg` | `imageio-ffmpeg` 自带 ffmpeg 二进制,不用单独装 ffmpeg |
68
+ | Node 包 | 任意可用的 `playwright-core` | 用 `PLAYWRIGHT_MODULE` 指到它的路径即可,不必装进本项目 |
69
+ | 浏览器 | 任意 Chrome / Chromium 可执行文件 | 用 `CHROME_PATH` 指过去 |
70
+
71
+ 两个环境变量(Windows / Linux 都一样,`render.mjs` 与 `cover.mjs` 都认):
72
+
73
+ ```bash
74
+ PLAYWRIGHT_MODULE=/path/to/node_modules/playwright-core \
75
+ CHROME_PATH=/path/to/chrome.exe \
76
+ node lib/render.mjs --dir lessons/ep01
77
+ ```
78
+
79
+ ## 二、脚本 `script.json` 怎么写
80
+
81
+ 顶层字段:
82
+
83
+ | 字段 | 含义 |
84
+ | --- | --- |
85
+ | `id` | 集目录名与成片名(`out/<id>.mp4`),如 `ep01-regression` |
86
+ | `series` / `episode` / `brand` | 系列名 / 集标题(只在日志里用)/ **画面左上台标文字** |
87
+ | `wm` | 画面右上角水印文字;不给就整块隐藏(模板里没有任何固定文案) |
88
+ | `mark` | 台标方块的字母;不给就取 `brand` 的首字 |
89
+ | `voice` / `rate` | edge-tts 音色与语速,如 `zh-CN-YunxiNeural` / `+18%` |
90
+ | `gap` | 每幕之间的静音秒数,默认 0.35;一口气讲完的系列用 0.3 |
91
+ | `scenes` | 幕数组,见下 |
92
+
93
+ 每一幕:`id`(幕内短名,用来命名音频)、`type`(版式)、`narration`(**这一集真正要讲的话**),其余字段按 `type` 取。
94
+
95
+ | `type` | 特有字段 | 用途 |
96
+ | --- | --- | --- |
97
+ | `title` | `kicker` `title` `sub` `tags[]` | 开场:大标题 + 一句副标题 + 三两个标签 |
98
+ | `points` | `kicker` `title` `items[]` | 要点幕,`<em>` 标蓝关键词 |
99
+ | `math` | `kicker` `title` `steps[{label,formula,hi}]` | 公式卡,`hi: true` 把某张卡高亮;≥4 张自动切紧凑版式 |
100
+ | `code` | `kicker` `title` `file` `code` `code_reveal` | 代码卡,`code` 用 `\n` 分行,逐行出现 |
101
+ | `source` | 同 `code`,另加 `notes[]` | 代码 + 下方 2–3 条注记(读库源码时用) |
102
+ | `outro` | `kicker` `title` `sub` `cta` | 结尾 + CTA 按钮 |
103
+
104
+ ### 出现时机:写 `cue`,不写秒数
105
+
106
+ 任何元素上写 `"cue": "关键词"`,`tts.py` 会在**配音的逐词时间**里找这个关键词,把元素出现的时刻标成 `at`(相对本幕秒数,比人声早 0.15s)。
107
+
108
+ - 关键词必须**真的出现在同一幕的 `narration` 里**(`cue_time` 按去掉空白的字符流做子串匹配),否则静默失败、元素永不出现;
109
+ - 想要 CTA 晚点出,就写 `"cta_cue": "关注"` —— 任何 `<名>_cue` 都会解析成 `<名>_at`;
110
+ - 已经手写了 `at` 的不会被覆盖,两种写法可以混用;
111
+ - 中文数字按念法写关键词:旁白念「负二点三四」,cue 就写 `负二点三四`,别写 `-2.34`。
112
+
113
+ ### 字数与节奏(实测)
114
+
115
+ 中文旁白约 **5.9 字/秒**(`rate` 为 `+18%`、`zh-CN-YunxiNeural`)——**3 分钟 ≈ 1070 字**,按这个数分配每一幕。写完先估一遍,别等合成完才发现超时。
116
+
117
+ ## 三、画面规矩(都在 `kit/deck.html` 里,改一处全片生效)
118
+
119
+ **安全区**:抖音等 App 的按钮、状态栏会压住画面边缘,所以两侧和上部必须留白。当前版式:
120
+
121
+ | 位置 | 数值 |
122
+ | --- | --- |
123
+ | 场景内边距 | `padding: 300px 185px 450px 150px`(上 / 右 / 下 / 左) |
124
+ | 内容可用区 | 宽 **745px**、高 **1170px**(y=300 → y=1470) |
125
+ | 左上台标 | `left:150px; top:172px` |
126
+ | 右上水印 | `right:178px; top:180px` |
127
+ | 字幕带 | y=1470 起,两行 46px/36px,居中 860px 宽 |
128
+
129
+ 由此推出两条硬约束:
130
+
131
+ 1. **内容底边 < 1470**(否则盖住字幕);
132
+ 2. **代码行 ≤ 约 55 个西文字符**(卡片内宽 697px ÷ 等宽字体 12.65px/字;实测 51 字符安全)。代码里长行放不下就换行、或把变量名缩短,别指望它自己折 —— `.card-bd` 是 `overflow:hidden`,超了会被**悄悄裁掉**。
133
+
134
+ 另外两点:
135
+
136
+ - `kicker` 会被 CSS `text-transform:uppercase`,**别在 kicker 里写希腊字母或中文以外的敏感字形**;
137
+ - 标题用了 `text-wrap:balance`,折行时左右均衡,避免最后一行只剩一两个字。
138
+
139
+ ## 四、封面 `covers.json` + `covers/out/`
140
+
141
+ ```bash
142
+ PLAYWRIGHT_MODULE=… CHROME_PATH=… \
143
+ node lib/cover.mjs --data covers/covers.json --out covers/out --sizes 1920,1440
144
+ ```
145
+
146
+ 每条封面一个对象:`id`(文件名前缀)`no`(集号,右上角大数字)`brand` `wm` `cta` `titleLines[]`(**自己写好断行**,最后一行自动标蓝)`subLines[]`(每行别超过约 20 字,多了会折成三行)`pills[]`(三个标签)`tag`(可选,接在「第 N 集」后面)`foot`(左下角一行)。
147
+
148
+ 一次出两张:`<id>-9x16.png`(1080×1920,视频首图)和 `<id>-1080x1440.png`(1080×1440,主页封面)。
149
+
150
+ ## 五、发布物料
151
+
152
+ 写进项目的 `covers/copy.md`,一集一段:**标题**(一句话,最好带一个真实数字)、**文案**(三行要点 + 每行一句解释)、**标签**。
153
+
154
+ - 抖音描述**标签最多 5 个**(如 `#Kaggle #机器学习 #分类 #逻辑回归 #Python`),多了不显示;
155
+ - 文案里的数字**必须是片子里真跑出来的**,不要编——这一条比排版重要得多。
156
+
157
+ ## 六、踩过的坑
158
+
159
+ 1. **Python 找不到 stdout 编码(Windows)**:控制台是 GBK,任何 `print` 中文或 `python -c` 都会 `UnicodeEncodeError`。所有 Python 调用前加 `PYTHONUTF8=1`。
160
+ 2. **`edge-tts` 偶尔空手而归**(`NoAudioReceived`):`tts.py` 里已带 4 次重试,别自己再包一层;真的是网络断了再排查。
161
+ 3. **必须显式要 `boundary="WordBoundary"`**:edge-tts 7.x 默认按句返回边界,拿不到逐词时间,字幕就贴不上人声。
162
+ 4. **Python 写回的 JSON 是 CRLF**:要改已经生成过的 `lesson.json`,别用文本替换,改用 Python 脚本读写。
163
+ 5. **帧序列很占地方**:3 分钟的片子约 1.5 GB(5511 张 JPEG)。确认成片没问题后可以删 `lessons/<集>/frames/`,要重渲再跑一次 `render.mjs`。
164
+ 6. **渲染前千万别跳过 `--check`**:内容越线、代码被裁、标题孤字,这三类问题在成片里很难发现,在自检里一眼就能看到。
165
+ 7. **帧数与时间轴不符**:`build.py` 会比对帧数与 `duration × fps`,差超过 2 帧就报警 —— 那通常说明渲染中途被打断了,重渲。
166
+ 8. **`PLAYWRIGHT_MODULE` 指的必须是 playwright-core 的目录**(含 `package.json` 的那层),不是 `playwright`。
167
+
168
+ ## 七、边界
169
+
170
+ - 画面是**时间 t 的纯函数**,没有 CSS 过渡、没有随机数:同一份 `script.json` 每次渲染结果一致,改一句话重渲即可,不需要剪辑软件。
171
+ - 这套版式只做 1080×1920 竖屏;横屏或方形要另做版式(`deck.html` 的 `html,body` 宽高与场景内边距是硬编码的)。
172
+ - 配音只走 edge-tts(免费、无需密钥)。要换别的 TTS,替换 `tts.py` 里的 `synth()` 即可,其余步骤不用动。
@@ -0,0 +1,14 @@
1
+ [
2
+ {
3
+ "id": "demo-gap",
4
+ "no": "1",
5
+ "brand": "示例教程 · 1 集",
6
+ "wm": "@GitHub/your-handle",
7
+ "cta": "关注抖音号 your-handle",
8
+ "titleLines": ["一句话", "讲一件事"],
9
+ "subLines": ["两行副标题,每行别超过二十来个字", "第二行放一个真实数字"],
10
+ "pills": ["标签一", "标签二", "标签三"],
11
+ "tag": "示例",
12
+ "foot": "示例系列 · 第 1 集"
13
+ }
14
+ ]
@@ -0,0 +1,53 @@
1
+ {
2
+ "id": "demo-gap",
3
+ "series": "示例系列",
4
+ "episode": "第 1 集 · 示例",
5
+ "brand": "示例教程 · 1 集",
6
+ "wm": "@GitHub/your-handle",
7
+ "voice": "zh-CN-YunxiNeural",
8
+ "rate": "+18%",
9
+ "gap": 0.3,
10
+ "scenes": [
11
+ {
12
+ "id": "s1",
13
+ "type": "title",
14
+ "kicker": "第 1 集 · 示例",
15
+ "title": "一集三分钟,讲清一件事",
16
+ "sub": "标题、要点、代码,三幕都要有",
17
+ "tags": ["示例", "竖屏", "课件"],
18
+ "narration": "这一集是模板示例。三分钟讲一件事,画面由时间轴驱动,配音和字幕都是从同一份脚本里长出来的。"
19
+ },
20
+ {
21
+ "id": "s2",
22
+ "type": "points",
23
+ "kicker": "01 · 要点",
24
+ "title": "要点幕:三行字就够",
25
+ "items": [
26
+ "每行一句话,<em>关键词</em>用 em 标蓝",
27
+ "cue 写在 content 上,配音念到就出现",
28
+ "整幕内容不能越过 y=1470 的字幕安全线"
29
+ ],
30
+ "narration": "要点幕放三行就够。关键词用 em 标出来,配音念到哪个词,哪一行就跟着出现,不用手算时间。"
31
+ },
32
+ {
33
+ "id": "s3",
34
+ "type": "code",
35
+ "kicker": "02 · 代码",
36
+ "title": "代码幕:行宽是关键",
37
+ "file": "demo.py",
38
+ "code": "def hello(name):\n return f\"hi {name}\"",
39
+ "code_reveal": 3,
40
+ "narration": "代码幕要注意行宽,太长的行会被卡片裁掉,渲染前的自检会告你哪一行越界。"
41
+ },
42
+ {
43
+ "id": "s4",
44
+ "type": "outro",
45
+ "kicker": "下一集",
46
+ "title": "把标题写在这里",
47
+ "sub": "副标题写一句就够了",
48
+ "cta": "关注抖音号 your-handle",
49
+ "cta_cue": "关注",
50
+ "narration": "好,这一集就到这。关注我,下一集见。"
51
+ }
52
+ ]
53
+ }
@@ -0,0 +1,78 @@
1
+ """把帧序列 + 配音轨 + 字幕合成成竖屏 MP4(1080x1920,H.264/AAC,抖音可直传)。
2
+
3
+ 用法: python lib/build.py lessons/ep00-pilot [--out out/pilot.mp4]
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+ import imageio_ffmpeg
13
+
14
+ FFMPEG = imageio_ffmpeg.get_ffmpeg_exe()
15
+
16
+
17
+ def srt_time(sec: float) -> str:
18
+ ms = int(round(sec * 1000))
19
+ h, ms = divmod(ms, 3600_000)
20
+ m, ms = divmod(ms, 60_000)
21
+ s, ms = divmod(ms, 1000)
22
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
23
+
24
+
25
+ def write_srt(subs: list[dict], path: Path) -> None:
26
+ lines = []
27
+ for i, s in enumerate(subs, 1):
28
+ lines += [str(i), f"{srt_time(s['start'])} --> {srt_time(s['end'])}", s["text"], ""]
29
+ path.write_text("\n".join(lines), encoding="utf-8")
30
+
31
+
32
+ def main() -> None:
33
+ ap = argparse.ArgumentParser()
34
+ ap.add_argument("dir")
35
+ ap.add_argument("--out")
36
+ ap.add_argument("--crf", type=int, default=18)
37
+ args = ap.parse_args()
38
+
39
+ d = Path(args.dir).resolve()
40
+ lesson = json.loads((d / "lesson.json").read_text(encoding="utf-8"))
41
+ frames = sorted((d / "frames").glob("f_*.jpg"))
42
+ if not frames:
43
+ raise SystemExit(f"没有帧,先跑 render.mjs:{d / 'frames'}")
44
+ audio = d / "audio" / "narration.wav"
45
+ out = Path(args.out) if args.out else d / "out" / f"{lesson['meta']['id']}.mp4"
46
+ if not out.is_absolute():
47
+ out = d / out
48
+ out.parent.mkdir(parents=True, exist_ok=True)
49
+
50
+ fps = lesson["fps"]
51
+ expect = round(lesson["duration"] * fps)
52
+ if abs(len(frames) - expect) > 2:
53
+ print(f"⚠ 帧数与时间轴不符:{len(frames)} vs {expect}")
54
+
55
+ write_srt(lesson["subtitles"], out.with_suffix(".srt"))
56
+
57
+ cmd = [
58
+ FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
59
+ "-framerate", str(fps), "-start_number", "0", "-i", str(d / "frames" / "f_%05d.jpg"),
60
+ "-i", str(audio),
61
+ # JPEG 帧是全范围,转成广播标准的 limited range + bt709,避免平台转码后发灰
62
+ "-vf", "scale=in_range=full:out_range=tv,format=yuv420p",
63
+ "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709",
64
+ "-c:v", "libx264", "-preset", "slow", "-crf", str(args.crf),
65
+ "-profile:v", "high", "-level", "4.2",
66
+ # 社媒响度:-14 LUFS / 真峰值 -1.5 dBTP
67
+ "-af", "loudnorm=I=-14:TP=-1.5:LRA=11",
68
+ "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "1",
69
+ "-shortest", "-movflags", "+faststart",
70
+ str(out),
71
+ ]
72
+ subprocess.run(cmd, check=True)
73
+ print(f"OK {out} {out.stat().st_size / 1024**2:.1f} MB "
74
+ f"{lesson['duration']:.1f}s @ {fps}fps 字幕 {out.with_suffix('.srt').name}")
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()