流水线开跑前先问用户用哪个 TTS。两引擎产出的 audio-manifest.json
结构完全一致,故 timeline_build / subs / 渲染器零改动。
新增
- scripts/tts_volcano.py 接口包,唯一读 tts.env 的地方。零第三方依赖
(只用标准库 urllib),HTTP Chunked 单向流式,
与 edge-tts「发一次收一串」形态同构
- scripts/tts_setup.py 向导:选方案 → 配密钥 → 测连接 → 选音色,
输出 NEXT_ACTION=… 供智能体识别
- tts.env.example 密钥模板(真实 tts.env 已 gitignore)
- references/volcano-tts.md 密钥纪律 / 配置步骤 / 接口规格 / 音色表 / 排查表
安全
- Key 只存 tts.env,只有 tts_volcano.py 读它;异常过脱敏,只抹本次实际
加载的密钥值与 AKLT… 形态,刻意不用「长字符串即密钥」的宽规则
- check_integrity.py 新增密钥防线检查(含「疑似把模板改名」提示)
- package_skill.py 排除 .env / .key / .pem,修掉一个真实外泄口
修复(真机联调暴露,均只在实跑时复现)
- enable_subtitle 必须显式开启,否则 words 恒为空数组且完全不报错,
下游会静默退回按字数插值、字幕开始飘
- usage 挂在结束标记那一行,提前 continue 导致计费恒为 0
- 缓存未存首裁量,命中缓存时 lead_cut_sec=0,重跑字幕比首跑晚一帧
- CLI 把中文音色名原样当 speaker 发出,服务端报 resource ID mismatched
文档
- README 中英双语新增「配音:两个引擎」章节,按用户要求不写命令
(那是智能体的职责),只讲「会问你什么 / 你唯一要动手的地方」
- 疑难排查补 6 条火山症状;lessons 34 → 56 条;流水线图加 tts_setup 节点
183 lines
8.4 KiB
Python
183 lines
8.4 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
new_project.py —— 建项目脚手架(复制模板、生成配置与主题、写占位解说词)。
|
||
|
||
用法:
|
||
python new_project.py <工作目录> <slug> [--topic "医疗"] [--preset amber]
|
||
[--fps 30] [--width 1920] [--height 1080]
|
||
|
||
产出结构:
|
||
<dir>/
|
||
project.json 全片配置(order = 场景顺序,写完解说词后填)
|
||
narration.json 解说词([{id, text}],text 用 | 切字幕块)
|
||
theme.css 主题配色(--topic/--preset 决定,后续可换)
|
||
assets/gsap.min.js
|
||
frames/_template.html 场景模板(复制改内容,契约见文件头注释)
|
||
frames/_template.beats.js(占位节拍,subs.py 跑完会被真数据覆盖格式参考)
|
||
research/ script/ audio/ render/ out/
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import shutil
|
||
import sys
|
||
|
||
SKILL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("dir")
|
||
ap.add_argument("slug")
|
||
ap.add_argument("--topic", default="")
|
||
ap.add_argument("--preset", default="")
|
||
ap.add_argument("--fps", type=int, default=30)
|
||
ap.add_argument("--width", type=int, default=1920)
|
||
ap.add_argument("--height", type=int, default=1080)
|
||
ap.add_argument("--lang", default="zh")
|
||
args = ap.parse_args()
|
||
|
||
proj = os.path.abspath(args.dir)
|
||
if os.path.exists(proj) and os.listdir(proj):
|
||
print(f"✗ 目录非空:{proj}", file=sys.stderr)
|
||
return 1
|
||
|
||
for d in ("frames", "audio", "render", "out", "research", "script", "assets"):
|
||
os.makedirs(os.path.join(proj, d), exist_ok=True)
|
||
|
||
# project.json
|
||
pj = {
|
||
"slug": args.slug,
|
||
"lang": args.lang,
|
||
"fps": args.fps,
|
||
"width": args.width,
|
||
"height": args.height,
|
||
"gap": 0.35,
|
||
"order": [], # 写完 narration.json 后把 id 按顺序填进来
|
||
"theme": "violet",
|
||
"progress": True,
|
||
# 配音引擎:edge = 免费免密钥;volcano = 火山引擎语音合成 2.0(需 tts.env)。
|
||
# ★ 建项目后先问用户用哪个 —— python <skill>/scripts/tts_setup.py --project .
|
||
"provider": "edge",
|
||
"voice": "zh-CN-YunxiNeural" if args.lang == "zh" else "en-US-GuyNeural",
|
||
"rate": "+8%",
|
||
}
|
||
json.dump(pj, open(os.path.join(proj, "project.json"), "w", encoding="utf-8"),
|
||
ensure_ascii=False, indent=2)
|
||
|
||
# narration.json 占位(示例含 | 切块写法)
|
||
narration = [
|
||
{"id": "intro", "text": "开场钩子,一到两句。|用竖线切字幕块,每块不超过16个字。"},
|
||
{"id": "point1", "text": "第一个论点。|画面节拍用B函数对齐这句。"},
|
||
]
|
||
json.dump(narration, open(os.path.join(proj, "narration.json"), "w", encoding="utf-8"),
|
||
ensure_ascii=False, indent=2)
|
||
|
||
# 模板与资源
|
||
shutil.copy(os.path.join(SKILL_ROOT, "assets", "gsap.min.js"),
|
||
os.path.join(proj, "assets", "gsap.min.js"))
|
||
tpl = open(os.path.join(SKILL_ROOT, "assets", "frame-template.html"), encoding="utf-8").read()
|
||
open(os.path.join(proj, "frames", "_template.html"), "w", encoding="utf-8", newline="\n").write(
|
||
tpl.replace("TEMPLATE_ID.beats.js", "_template.beats.js"))
|
||
|
||
# 封面模板两份:直接建进 frames/,用户改内容即可(默认写在 cover_169/cover_34 下)
|
||
# —— 起始尺寸已按目标画幅设好,只改文案与构型
|
||
cover_src = open(os.path.join(SKILL_ROOT, "assets", "cover-template.html"), encoding="utf-8").read()
|
||
open(os.path.join(proj, "frames", "cover_169.html"), "w", encoding="utf-8", newline="\n").write(
|
||
cover_src.replace("1920px; height: 1080px", "1920px; height: 1080px"))
|
||
open(os.path.join(proj, "frames", "cover_34.html"), "w", encoding="utf-8", newline="\n").write(
|
||
cover_src.replace("1920px; height: 1080px", "1440px; height: 1080px"))
|
||
|
||
# 占位 beats(预览模板用;正式场景的 beats 由 subs.py 生成)
|
||
stub = """/* 占位节拍(new_project.py 生成,仅供 _template.html 预览)。
|
||
* 正式节拍由 subs.py 生成:解说词定稿 + tts_build 跑完后自动刷新。 */
|
||
window.__BEATS__ = [
|
||
{ text: "开场钩子,一到两句。", start: 0.30, end: 2.40 },
|
||
{ text: "用竖线切字幕块,每块不超过16个字。", start: 2.40, end: 4.80 },
|
||
{ text: "左边卡片", start: 0.40, end: 2.00 },
|
||
{ text: "右边卡片", start: 1.80, end: 3.60 },
|
||
{ text: "结论句", start: 3.60, end: 5.60 },
|
||
{ text: "证据", start: 6.00, end: 8.00 }
|
||
];
|
||
window.__SEG__ = { id: "_template", duration: 9.0, speech_end: 8.2, tail: 0.8 };
|
||
(function () {
|
||
var B = window.__BEATS__;
|
||
function find(text) {
|
||
var t = String(text || '').replace(/[\\s,。、!?;:,.!?;:|]/g, '');
|
||
for (var i = 0; i < B.length; i++) {
|
||
var bt = B[i].text.replace(/[\\s,。、!?;:,.!?;:|]/g, '');
|
||
if (bt === t || bt.indexOf(t) === 0 || t.indexOf(bt) === 0) return B[i];
|
||
}
|
||
throw new Error('B() 找不到节拍: ' + text);
|
||
}
|
||
window.B = function (text) { return find(text).start; };
|
||
window.Be = function (text) { return find(text).end; };
|
||
})();
|
||
"""
|
||
open(os.path.join(proj, "frames", "_template.beats.js"), "w", encoding="utf-8", newline="\n").write(stub)
|
||
|
||
# 主题
|
||
import subprocess
|
||
py = sys.executable
|
||
theme_args = [py, os.path.join(SKILL_ROOT, "scripts", "make_theme.py"), "--project", proj, "--use"]
|
||
if args.topic:
|
||
theme_args += ["--topic", args.topic]
|
||
elif args.preset:
|
||
theme_args += ["--preset", args.preset]
|
||
else:
|
||
theme_args += ["--preset", "violet"]
|
||
r = subprocess.run(theme_args, capture_output=True, text=True, encoding="utf-8")
|
||
print(r.stdout.strip() or r.stderr.strip())
|
||
|
||
readme = f"""# {args.slug}(html-explainer 项目)
|
||
|
||
## ★ 先挑画面风格(别从零设计)
|
||
`<skill>/references/style-catalog.md` —— **23 个模板风格 / 8 个类别**
|
||
(大胆信号卡、奢华极简、NYT 数据图表、瑞士网格、故障艺术、胶片漏光、流体 Hero、
|
||
Logo 收尾、东方柔和有机、VFX 文字光标…),每种含画布/字体/时间轴/配色规范。
|
||
|
||
两类模板改编成本不同:
|
||
- **★ rich(12 个)**:单文件 + 纯 CSS @keyframes → 换系统字体栈 + 让出字幕带 + 填内容,即可用
|
||
- **gsap(11 个)**:多 composition + CDN GSAP → **不要搬代码**,只取视觉 DNA 用 CSS 重写
|
||
|
||
改编细则见 `<skill>/references/template-guide.md`。
|
||
建议全片轮换 2–4 种风格,避免每场景一个样。
|
||
|
||
## 流程速查(详情见技能 SKILL.md)
|
||
1. research/调研.md —— 按 reference/workflow-guide.md 派研究员,事实带 URL
|
||
2. narration.json —— 解说词定稿(`|` 切字幕块,中文每块 ≤16 字)
|
||
3. project.json 的 order 填场景顺序(= narration.json 的 id 顺序)
|
||
4. 分镜 script/storyboard.md —— **每场景先指定用哪个模板风格**
|
||
5. 配音与时间轴:
|
||
python <skill>/scripts/tts_build.py --project .
|
||
python <skill>/scripts/timeline_build.py --project .
|
||
python <skill>/scripts/subs.py --project .
|
||
6. 每个场景一个 frames/<id>.html(改编挑中的模板,或从 _template.html 复制;节拍用 B())
|
||
7. 渲染:node <skill>/scripts/render_video.mjs . [--preview 30]
|
||
8. QC:python <skill>/scripts/qc_check.py --project .
|
||
|
||
## ★ 封面双方案(成片后必做)
|
||
复制 `assets/cover-template.html` 做**两份独立排版**的封面:
|
||
frames/cover_169.html 1920x1080 -> 抖音主封面(信息流/播放页)
|
||
frames/cover_34.html 1440x1080 -> 兼容主页 3:4 栅格(防切字)
|
||
**不是裁切关系**:16:9 裁 3:4 会丢掉 57.8% 的画面宽度,大字钩子必被切。
|
||
两份共享配色/主视觉/钩子文案,但各自重排(悖论视觉从右侧并置改成上方竖排、
|
||
钩子从两行改成三行)。详规见 `<skill>/references/cover-guide.md`。
|
||
渲染:node <skill>/scripts/cover_build.mjs .
|
||
|
||
主题:{args.topic or args.preset or 'violet'}(make_theme.py --topic ... --use 可换)
|
||
"""
|
||
open(os.path.join(proj, "README.md"), "w", encoding="utf-8", newline="\n").write(readme)
|
||
|
||
print(f"✓ 项目建好:{proj}")
|
||
print(" 下一步:调研 → 解说词定稿 → 分镜(挑模板风格)→ 填 project.json.order")
|
||
print(" 风格库:references/style-catalog.md(23 个模板 / 8 类别)")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|