@dsh-gis/dsh-video-studio-skill 0.6.0 → 0.8.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/CHANGELOG.md +951 -0
- package/README.md +89 -21
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +23 -7
- package/package.json +7 -3
- package/schemas/film.schema.json +235 -4
- package/skills/video-studio/SKILL.md +299 -27
- package/skills/video-studio/assets/film.json +7 -14
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/scenes.py +115 -10
- package/skills/video-studio/assets/web/engine.js +81 -3
- package/skills/video-studio/assets/web/film.js +249 -101
- package/skills/video-studio/assets/web/index.html +9 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +159 -0
- package/skills/video-studio/references/audio.md +67 -5
- package/skills/video-studio/references/change-impact.md +2 -2
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/style-packs.md +7 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +16 -1
- package/skills/video-studio/scripts/filmcheck.py +202 -12
- package/skills/video-studio/scripts/filmkit.py +792 -56
- package/skills/video-studio/scripts/gates.py +161 -14
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +154 -23
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +1134 -78
- package/skills/video-studio/scripts/scorekit.py +305 -76
- package/skills/video-studio/scripts/selfcheck.py +576 -2
- package/skills/video-studio/scripts/sfxkit.py +262 -44
- package/skills/video-studio/scripts/stagewatch.py +159 -36
- package/skills/video-studio/scripts/stylekit.py +137 -18
- package/skills/video-studio/scripts/voicekit.py +23 -7
- package/skills/video-studio/scripts/webengine.py +1186 -85
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +310 -59
- package/tools/estimate.py +251 -61
- package/tools/golden_frames.py +664 -0
- package/tools/selftest_noscipy.py +70 -0
|
@@ -110,7 +110,9 @@ assets/web/film.js 是 12 秒 / 3 个场景的完整模板:
|
|
|
110
110
|
1. 不碰 Math.random / Date.now / performance.now;随机数走 FK.hash / FK.rng(seed)。
|
|
111
111
|
2. 不读上一帧状态:每帧清空画布、从头按 T 重画(不要写"每帧位移 += speed"这种累加)。
|
|
112
112
|
3. 字体固定:canvas 内 fallback 到 "Microsoft YaHei", sans-serif;文档字体加载完才 ready。
|
|
113
|
-
4.
|
|
113
|
+
4. 视口默认 1920x1080、devicePixelRatio=1;画布 CSS 尺寸与分辨率一致,截图才是 1:1 像素。
|
|
114
|
+
(v0.8:`style/width/height/fps` 可让画布跟着交付规格走——只允许 **16:9 横屏偶数尺寸**,
|
|
115
|
+
竖屏 / 4:3 / 奇数在 `open_page` 入口就 raise;尺寸由 `window.__STYLE__.canvas` 注入。)
|
|
114
116
|
5. 动画相位只用绝对时间 T(如 Math.sin(T*2)),不要用帧计数差或挂钟。
|
|
115
117
|
6. 离屏缓存(发光精灵)在 boot 阶段建好,之后只读不写。
|
|
116
118
|
7. 质检项:自检里"同一帧两次截图 byte 完全相同"就是这条守则的守门员。
|
|
@@ -121,12 +123,27 @@ assets/web/film.js 是 12 秒 / 3 个场景的完整模板:
|
|
|
121
123
|
|---|---|
|
|
122
124
|
| 1920x1080 PNG 截图 + 页面绘制 | 约 70–120 ms/帧(2 workers 时 70–85 ms/帧均摊) |
|
|
123
125
|
| 12 秒 / 360 帧 / 2 workers / chunk 3s | 28.0 s 出片(约 78 ms/帧均摊) |
|
|
124
|
-
|
|
|
126
|
+
| 自检(**35 项**,含真实无头浏览器、跨会话确定性验证与 v0.8 风格注入断言) | 约 60 s |
|
|
125
127
|
|
|
126
128
|
调参顺序:先降 workers(1–2 个足够,多了内存涨),再降 chunk_s(块小重渲代价低),
|
|
127
129
|
最后才动 crf/preset(crf 17 / preset slow 是质量档;预览可用 crf 20 / preset veryfast)。
|
|
128
130
|
PNG 截图比 JPEG q94 慢但无压缩伪影,本模块按契约固定 PNG。
|
|
129
131
|
|
|
132
|
+
### 8.1 v0.8:跟随风格包(`window.__STYLE__`)
|
|
133
|
+
|
|
134
|
+
`--engine web` 以前画风写死深蓝橙,现在是「`style` plan 驱动」:`open_page` 在 `page.goto`
|
|
135
|
+
**之前**用 `add_init_script` 注入 `window.__STYLE__`(主题色板 / 版式 / 字体栈 / 画布 `w·h·fps`),
|
|
136
|
+
`assets/web/engine.js` 与 `film.js` 优先读它、取不到就退回 v0.7 的深蓝橙常量。
|
|
137
|
+
|
|
138
|
+
| 情形 | 行为 |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `style=None`(pil 路线 / 显式关闭) | **连 `add_init_script` 都不调用**;页面里不存在 `window.__STYLE__`,像素与 v0.7 逐字节一致(自检有断言) |
|
|
141
|
+
| 页面不认 `__STYLE__`(老 `engine.js`) | **当场报错**并给出入口页与 grep 提示,不能"截图能出图"就当通过 |
|
|
142
|
+
| 注入的画布尺寸与页面实际画布不一致 | 报错(页面没按 `panel` 建画布);`fps` 注入了却与 `window.FPS` 不一致也报错 |
|
|
143
|
+
|
|
144
|
+
> 为什么要在 goto 之前注入:`engine.js` / `film.js` 是同步 IIFE,加载时就要读到 `__STYLE__`,
|
|
145
|
+
> 页面开始跑之后再注入就晚了。
|
|
146
|
+
|
|
130
147
|
## 9. 文字质检 qaText
|
|
131
148
|
|
|
132
149
|
r = qa_text("assets/web/index.html", 0, 12, 0.2)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# 词级时间轴与词锚点(v0.8.0)
|
|
2
|
+
|
|
3
|
+
> 本页是 `work/words.json` 的**契约**、`cue at` 词锚点的**语义**,以及四个在真实跑里踩过的坑。
|
|
4
|
+
> 设计前提一句话:**句子时长仍以 TTS 实测时长为权威**;词级时间戳只是附加值,
|
|
5
|
+
> 用来折字幕、打锚点、让门禁发现"词事件突然没了"。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. 数据从哪来
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
film.json 的 "words": true(默认)
|
|
13
|
+
↓ --stage tts
|
|
14
|
+
edge_tts.Communicate(text, voice, rate=, pitch=, volume=,
|
|
15
|
+
boundary="WordBoundary").stream()
|
|
16
|
+
↓ 同一路 async for 里 chunk["type"]=="audio" 写文件 / 其余进 raw_events
|
|
17
|
+
work/tts/<id>.mp3.part --os.replace--> work/tts/<id>.mp3
|
|
18
|
+
↓ decode_pcm + trim_silence_bounds(算出被裁掉的句首秒数 shift)
|
|
19
|
+
work/tts/<id>.mp3.json 的 meta:words / words_key / words_events / words_shift / words_ok
|
|
20
|
+
↓ _write_words()
|
|
21
|
+
work/words.json ← G10、字幕折行、cue 词锚点**唯一**的事实来源
|
|
22
|
+
↓ build_timeline(..., fps=)
|
|
23
|
+
timeline.json 的 scenes[].words_abs (句内词表平移到绝对时间)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
三道防呆,缺一条都会出问题:
|
|
27
|
+
|
|
28
|
+
| 防呆 | 为什么 |
|
|
29
|
+
|---|---|
|
|
30
|
+
| 显式 `boundary="WordBoundary"` + `stream()` | 用 `save()` 会把词事件丢掉;不传 `boundary` 时默认 `SentenceBoundary`,**一条词事件都收不到**(实测 edge-tts 7.3.1 / zh-CN:词事件数 = 0) |
|
|
31
|
+
| 音频先写 `.part` 再 `os.replace` | 旧写法直接 `open(目标, "wb")`,一次断网/超时就把**已缓存的好音频截成 0 字节**。现在失败只丢本次新音频,旧缓存还在 |
|
|
32
|
+
| 词表独立缓存键 `tts_words_key` | 并进 `tts_key_v2` 会让**所有老项目的音频缓存一次性失效**(必须整片重新联网合成)。分开记 -> 老音频照旧复用,只在"这次显式要词级"时补采一次 |
|
|
33
|
+
|
|
34
|
+
### 1.1 `work/words.json` 契约
|
|
35
|
+
|
|
36
|
+
```jsonc
|
|
37
|
+
{
|
|
38
|
+
"schema": 1,
|
|
39
|
+
"enabled": true,
|
|
40
|
+
"lines": {
|
|
41
|
+
"l03": {
|
|
42
|
+
"dur": 7.1836, // 该句语音实测时长(秒,裁剪后)
|
|
43
|
+
"n": 13, // 词事件条数
|
|
44
|
+
"covered": 5.2013, // 词时长之和 —— 注意它**小于** dur(词间空隙 + 句尾静音)
|
|
45
|
+
"tail": 0.0901, // dur - 末词 end,句尾静音量
|
|
46
|
+
"start": 0.0412, // 首词起点(相对本句语音起点)
|
|
47
|
+
"gaps": [0.183, 0.207], // 相邻词之间的空隙(秒)
|
|
48
|
+
"mono": true, // 词偏移是否单调
|
|
49
|
+
"words": [{"text": "画质", "start": 0.0412, "dur": 0.2814, "end": 0.3226}, ...]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"stats": { "lines": 4, "words": 64, "empty": [] } // empty = 拿不到词事件的句 id
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`stats.empty` 非空就是"这些句这次没有词级"。它**不是崩溃**,而是服务端行为变了或者该句被裁坏了;
|
|
57
|
+
G10 会因此记 `skipped` 或 `fail`(看是全部为空还是个别句为空)。
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 2. 关键函数(`filmkit.py`)
|
|
62
|
+
|
|
63
|
+
| 函数 | 作用 | 退化行为 |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `tts_words_key(text, voice, rate, pitch, volume, trim, trim_db)` | 词级缓存的独立键 | — |
|
|
66
|
+
| `trim_silence_bounds(pcm, sr, thresh_db=-42.0, keep_ms=80)` | 返回 `trim_silence` 会保留的样本区间 `[a, b)`;`a/sr` 就是 `words_shift` | 全静音 -> `(0, 0)`,等于不裁剪 |
|
|
67
|
+
| `words_normalize(events, *, offset_shift=0.0, dur=None, min_gap=0.0)` | 100 ns 事件 -> `{text,start,dur,end}`;减 `offset_shift`;负起点钳到 0;按 `prev_end` 夹紧保证单调;末词 end 不超过 `dur` | 空事件 -> `[]` |
|
|
68
|
+
| `words_summary(words, dur=None)` | 出 `n / covered / gaps / tail / mono / start`(G10 判据全在这里算) | — |
|
|
69
|
+
| `sub_words_at(scenes, t)` | 当前上屏字幕对应的 `words_abs`,没有就 `None` | 无词级 -> `None`(调用方回退整句) |
|
|
70
|
+
| `wrap_by_words(text, f, max_w, words)` | 按真实词边界折行;词表与原文对不上(有尾差/切不开)就返回 `None` | `None` -> `fit_sub_lines` 回退字符折行 |
|
|
71
|
+
| `fit_sub_lines(..., words=)` / `draw_subtitle(..., words=)` | 排版入口,多一个可选参数 | `words=None` 时与 v0.7 逐像素一致 |
|
|
72
|
+
| `build_timeline(..., fps=)` | 给每段补 `words_abs`(句内词表平移到绝对时间) | 该句没有 `words` 就没有 `words_abs` |
|
|
73
|
+
| `cue_time(at, scenes=, total=)` | 解析 `at`;v0.8 增词锚点 | 见 §3 |
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 3. 词锚点:`"l03:w2"` / `"l03:w2+0.1"`
|
|
78
|
+
|
|
79
|
+
```jsonc
|
|
80
|
+
{"at": "l03:w2", "type": "impact"} // 第 3 段第 2 个词的起点
|
|
81
|
+
{"at": "l03:w2+0.1", "type": "impact"} // 该词起点 + 0.1 s(偏移可带符号)
|
|
82
|
+
{"at": "l03:w2-0.05", "type": "whoosh"} // 提前 50 ms
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- **下标从 1 开始**:`w1` = 第一个词(和"第 1 句是 l01"一致,不玩 0 基)。
|
|
86
|
+
- 解析优先读 `scenes[].words_abs`,没有就退到 `scenes[].words`(句内相对时间)。
|
|
87
|
+
- **找不到该词时静默退化**:`l03:w99` 不会炸,只打一条
|
|
88
|
+
`[WARN] cue at='l03:w99':l03 只有 13 个词,w99 不存在,退化为句偏移`,然后按 `l03+偏移` 处理。
|
|
89
|
+
—— 所以**改完台词一定要看 stdout 有没有这条 WARN**,否则你会以为自己精确打到了某个词。
|
|
90
|
+
- 场景 id 本身不存在时才 `raise ValueError`(先跑 layout,或者改用 `abs:秒`)。
|
|
91
|
+
- 只有 `abs:12.5` / `12.5` / `l03` / `l03+0.35` 这几种老写法时行为与 v0.7 **完全一致**。
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 4. 四个坑(都是实测踩出来的)
|
|
96
|
+
|
|
97
|
+
### 4.1 词间有 0.1–0.3 s 的空隙
|
|
98
|
+
|
|
99
|
+
edge-tts 的 WordBoundary 只覆盖"发音区间",相邻词之间的停顿**不在任何事件里**。
|
|
100
|
+
所以 `covered = Σ dur` 永远小于句长。模板片实测 4 句 64 词,覆盖率约 0.7 量级。
|
|
101
|
+
**判据不能写 `covered == dur`**——G10 的覆盖率下限是 **45%**(低于它才是"明显少了")。
|
|
102
|
+
|
|
103
|
+
### 4.2 句尾静音不在事件里(约 0.5 s)
|
|
104
|
+
|
|
105
|
+
排轴用的是**整句实测时长**,不是词时长求和。G10 只检查"末词终点离句尾不超过 **0.40 s**"
|
|
106
|
+
(模板片实测 0.09 s)。这条超过 0.40 s,通常不是"片子坏了",而是词事件本身整体错位了。
|
|
107
|
+
|
|
108
|
+
### 4.3 裁剪会让词整体提前(`words_shift`)
|
|
109
|
+
|
|
110
|
+
`trim_silence=True`(默认)会裁掉句首尾静音,时间轴用的是裁剪后的语音;但词事件的 offset 是相对
|
|
111
|
+
**原始 mp3** 的。`trim_silence_bounds()` 与 `trim_silence()` 共用同一套判据,
|
|
112
|
+
`words_normalize(offset_shift=a/sr)` 负责平移。**不平移,词会整体提前 0.1–1 s**
|
|
113
|
+
(模板句实测 shift ≈ 0.10 s 量级)。这两个函数必须**永远**共用判据,改一个不改另一个就会系统偏移。
|
|
114
|
+
|
|
115
|
+
### 4.4 首词起点也要查
|
|
116
|
+
|
|
117
|
+
裁剪后首词通常离句首 ≤ 0.05 s;G10 的上限是 **0.20 s**。这条超了,多半是"裁掉的静音量算错了"
|
|
118
|
+
或"词事件从更早的句子串过来了"——两种都会让字幕比声音先亮。
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 5. 自己验一遍(不联网也能查一半)
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# 1) 词级有没有真的落盘(看每句 n / covered / tail)
|
|
126
|
+
python -c "import json;d=json.load(open('work/words.json',encoding='utf-8'));print(d['stats']);[print(k, v['n'], v['covered'], v['tail']) for k,v in d['lines'].items()]"
|
|
127
|
+
|
|
128
|
+
# 2) 词锚点解析(故意写一个不存在的词,必须看到 WARN)
|
|
129
|
+
python -c "import filmkit as K, json; tl=json.load(open('work/timeline.json',encoding='utf-8')); \
|
|
130
|
+
print(K.cue_time('l01:w1', scenes=tl['scenes'])); print(K.cue_time('l01:w999', scenes=tl['scenes']))"
|
|
131
|
+
|
|
132
|
+
# 3) 全片门禁视角
|
|
133
|
+
python render.py ... --stage qa # 看 QC-REPORT.md 的 G10 行
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**可证伪性检查**(这条最值钱):随便把某句的 `work/words.json` 手改一个词的 `start` 到乱序,
|
|
137
|
+
重跑 `--stage qa`,G10 **必须**报"词偏移非单调"。不报就说明门禁没读到那个文件。
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## 6. 关掉它
|
|
142
|
+
|
|
143
|
+
- 单片:`film.json` 写 `"words": false` -> `--stage tts` 不再收词事件,G10 `skipped`(写明"本片未启用"),
|
|
144
|
+
字幕退回整句折行。**老项目想完全保持 v0.7 行为就写这一行。**
|
|
145
|
+
- 只跳过门禁不跳过能力:没有开关——G10 就是词级数据的体检,没数据就 skipped,不会 fail。
|
|
146
|
+
- 词事件拿不到(服务端变了):不阻断出片,G10 `skipped` / `fail` 二选一看覆盖面,
|
|
147
|
+
meta 里 `words_ok=false`、`words_events=0` 是排查入口。
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
#
|
|
3
3
|
# pip install -r requirements.txt # 必需 + 强烈建议(可选项默认注释,按需打开)
|
|
4
4
|
#
|
|
5
|
-
# 分三档,与 SKILL.md §
|
|
5
|
+
# 分三档,与 README「环境前提」(必需 / 可选两段)对应;SKILL.md §10 的脚本表写了每个文件依赖谁。
|
|
6
|
+
# 强烈建议档在这里单独列出(README 把它并进"可选"一句话里,档位以本文件为准)。
|
|
6
7
|
|
|
7
8
|
# ---- 必需:缺任何一条,主体流程跑不起来 ----
|
|
8
9
|
numpy>=1.24
|
|
@@ -23,6 +24,12 @@ opencv-python-headless>=4.8
|
|
|
23
24
|
# 装完还要下载浏览器:python -m playwright install chromium
|
|
24
25
|
# playwright>=1.40
|
|
25
26
|
|
|
27
|
+
# ---- 可选:v0.8.0 ASR 回读门禁(qaasr.py)----
|
|
28
|
+
# 把成片音轨转回文字与剧本对账,抓"TTS 缓存串了 / 混音丢了句子 / 音轨是旧的"。
|
|
29
|
+
# 缺了会依次降级:faster-whisper -> ffmpeg 的 whisper 滤镜(需 ggml 模型)-> skipped。
|
|
30
|
+
# 实测(CPU-only, int8):small 模型加载 2.0–3.3 s、转写 RTF≈0.17;29 s 模板片约 6 s。
|
|
31
|
+
# faster-whisper>=1.0
|
|
32
|
+
|
|
26
33
|
# ---- v0.6.0 新增文件:都是纯标准库,**不需要**任何 pip 包 ----
|
|
27
34
|
# scripts/gates.py 门禁账本 / QC 报告 / 人工签核(必需:render.py 直接 import)
|
|
28
35
|
# scripts/stagewatch.py 阶段新鲜度(必需:同上)
|
|
@@ -31,6 +38,14 @@ opencv-python-headless>=4.8
|
|
|
31
38
|
# 若你从旧版本升级,记得把 gates.py / stagewatch.py 一起拷到工作目录,
|
|
32
39
|
# 否则 render.py 会 ModuleNotFoundError。
|
|
33
40
|
|
|
41
|
+
# ---- v0.8.0 新增文件(同样不需要 pip 包;升级记得一起拷)----
|
|
42
|
+
# scripts/preflight.py 渲染前资源闸门(可选:缺了就记 skipped,门禁不冒充通过)
|
|
43
|
+
# scripts/qavmaf.py 全片 VMAF/SSIM(可选:缺了就 skipped)
|
|
44
|
+
# scripts/qaasr.py ASR 回读(可选:缺了就 skipped)
|
|
45
|
+
# tools/golden_frames.py 金帧回归(可选:仓库工具,不进工作目录)
|
|
46
|
+
# 注意:这三个都**不是** render.py 的硬依赖(用了 _try_module),少拷只会让对应门禁
|
|
47
|
+
# 记 skipped,不会 ModuleNotFoundError;但 qa 报告里会明确写"本次没查"。
|
|
48
|
+
|
|
34
49
|
# ---- 不需要 ----
|
|
35
50
|
# moviepy / pydub / soundfile / librosa / manim:本技能一行都不 import。
|
|
36
51
|
# ffmpeg 不是 pip 包:系统 PATH 里要有(含 libx264 / aac / loudnorm),
|
|
@@ -52,6 +52,11 @@ PITCH_RE = re.compile(r"^\s*[+-]?\d+\s*Hz\s*$")
|
|
|
52
52
|
ID_RE = re.compile(r"^[A-Za-z0-9_.\-]+$")
|
|
53
53
|
|
|
54
54
|
CPS = 4.2 # 每秒约 4.2 字(voicekit 实测中位口径)
|
|
55
|
+
# 语速区间而不是单一 4.2:voicekit 校准实测 3.84–4.61 cps(9 个 persona,固定校准句),
|
|
56
|
+
# 而 stylekit 的声线策略允许的 cps_band 是 3.6–5.6(rate 覆盖 ±15% 后)。
|
|
57
|
+
# 只用中位数会误报:模板片实测 5.2 字/秒的句子被按 4.2 估 -> 全片多算 5.2 s ->
|
|
58
|
+
# "剧本与 timeline 差 5.23 s"的假 WARN(v0.7.2 修,模板片复现)。
|
|
59
|
+
CPS_LO, CPS_HI = 3.6, 5.6
|
|
55
60
|
DEFAULT_LEAD, DEFAULT_TAIL, DEFAULT_MIN_DUR = 0.62, 0.92, 3.0
|
|
56
61
|
TIMELINE_TOL = 0.5 # 估算与 timeline 差超过这个秒数就 WARN
|
|
57
62
|
|
|
@@ -286,6 +291,11 @@ SCRIPT_SPEC = {
|
|
|
286
291
|
"seed": {"t": "int", "min": 0},
|
|
287
292
|
"seed_from_content": {"t": "bool"},
|
|
288
293
|
"gain_db": {"t": "num"}, "duck_db": {"t": "num"},
|
|
294
|
+
# v0.7.1 旁白优先:mix_film 读这四个(背景在人声处额外让位多少 / 阈值)
|
|
295
|
+
"voice_duck_db": {"t": "num"}, "voice_duck_sfx_db": {"t": "num"},
|
|
296
|
+
"voice_thresh_db": {"t": "num"}, "voice_floor_db": {"t": "num"},
|
|
297
|
+
# v0.7.1 SNR 兜底:混音后实测人声/背景差,不足就整体压背景
|
|
298
|
+
"guard_snr_db": {"t": "num"}, "guard_cap_db": {"t": "num"},
|
|
289
299
|
"seg": {"t": "num", "min": 0.0}, "amp": {"t": "num", "min": 0.0},
|
|
290
300
|
"gain": {"t": "num", "min": 0.0}, "xfade": {"t": "num", "min": 0.0},
|
|
291
301
|
"chords": {"t": "list", "item": {"t": "list", "item": {"t": "num"}}},
|
|
@@ -294,10 +304,57 @@ SCRIPT_SPEC = {
|
|
|
294
304
|
"enabled": {"t": "bool"},
|
|
295
305
|
"gain_db": {"t": "num"},
|
|
296
306
|
"mix_gain_db": {"t": "num"},
|
|
307
|
+
# render.py 的 st_sfx 把它透传给 CueBus.build_audio():按配方名覆盖基础电平。
|
|
308
|
+
# 以前 schema/filmcheck 都没写,用户写了会被当未知键警告(其实真的会生效)。
|
|
309
|
+
"base_db": {"t": "dict", "open": True},
|
|
310
|
+
# v0.7 音频差异化:content_seed 由 CueBus 读(位置派生 vs 内容派生);
|
|
311
|
+
# duck_transitions 由 render.st_sfx 读(段落边界前的转场音效自动让位)。
|
|
312
|
+
"content_seed": {"t": "bool"},
|
|
313
|
+
"duck_transitions": {"t": "bool"},
|
|
297
314
|
"cues": {"t": "list", "item": CUE_SPEC}}},
|
|
298
315
|
"web": {"t": "dict", "props": {
|
|
299
316
|
"entry": {"t": "str"},
|
|
300
317
|
"chunk_s": {"t": "num", "min": 0.0}}},
|
|
318
|
+
# ---- v0.8 ----
|
|
319
|
+
# 词级时间戳开关(字幕按词折行 / cue 词锚点 / G10 门禁都依赖它)
|
|
320
|
+
"words": {"t": "bool"},
|
|
321
|
+
# TTS 后端选择(v0.8 接口化;默认 edge,其它后端需先 register_tts_backend)
|
|
322
|
+
"tts": {"t": "dict", "props": {
|
|
323
|
+
"backend": {"t": "str"},
|
|
324
|
+
"voice": {"t": ("str", "dict")}}},
|
|
325
|
+
# 画幅与帧率:只允许 16:9 横屏 + 下采样输出(见 filmkit.video_plan 的取舍说明)
|
|
326
|
+
"video": {"t": "dict", "props": {
|
|
327
|
+
"width": {"t": "int", "min": 2},
|
|
328
|
+
"height": {"t": "int", "min": 2},
|
|
329
|
+
"out_width": {"t": "int", "min": 2},
|
|
330
|
+
"out_height": {"t": "int", "min": 2},
|
|
331
|
+
"fps": {"t": "int", "min": 1, "max": 120}}},
|
|
332
|
+
# 质检门禁参数(全片 VMAF/SSIM 阻断 + ASR 回读)
|
|
333
|
+
"qa": {"t": "dict", "props": {
|
|
334
|
+
"vmaf": {"t": "dict", "props": {
|
|
335
|
+
# enabled / require_vmaf 是 v0.8 起草时留下的**惰性键**:schema 与这里都认,
|
|
336
|
+
# 但 render.py / qavmaf.py 全仓没有读取点——写了它门禁照跑,用户以为自己关掉了。
|
|
337
|
+
# 真正生效的是 mode=off(或命令行 --vmaf off)。这里标 inert 让 filmcheck 报 WARN,
|
|
338
|
+
# 因为本项目的立场是"写对了却不生效"比"报未知键"更坏。
|
|
339
|
+
"enabled": {"t": "bool", "inert": "写它不生效;要跳过 G11 请写 qa.vmaf.mode=\"off\"(或 --vmaf off)"},
|
|
340
|
+
"mode": {"t": "str", "enum": ("full", "sample", "off")},
|
|
341
|
+
"pass": {"t": "num", "min": 0.0},
|
|
342
|
+
"warn": {"t": "num", "min": 0.0},
|
|
343
|
+
# v0.8.1:键名对齐 schemas/film.schema.json 与 qavmaf.report(sample_step=...)。
|
|
344
|
+
# 这里以前写的是 sample_s —— 全仓没有任何消费者(render 不读、qavmaf 只认
|
|
345
|
+
# sample_step),于是"写对了却完全没生效",而 schema 里合法写完还会被本脚本
|
|
346
|
+
# 报成未知键。别再兼容 sample_s:它从来没生效过,留着只会让人以为能用。
|
|
347
|
+
"sample_step": {"t": "int", "min": 1}}},
|
|
348
|
+
"ssim": {"t": "dict", "props": {
|
|
349
|
+
"enabled": {"t": "bool", "inert": "写它不生效;SSIM 本来就只是诊断项(不单独阻断)"},
|
|
350
|
+
"pass": {"t": "num", "min": 0.0, "max": 1.0},
|
|
351
|
+
"warn": {"t": "num", "min": 0.0, "max": 1.0}}},
|
|
352
|
+
"asr": {"t": "dict", "props": {
|
|
353
|
+
"enabled": {"t": "bool"},
|
|
354
|
+
"model": {"t": "str"},
|
|
355
|
+
"recall_pass": {"t": "num", "min": 0.0, "max": 1.0},
|
|
356
|
+
"recall_warn": {"t": "num", "min": 0.0, "max": 1.0}}},
|
|
357
|
+
"require_vmaf": {"t": "bool", "inert": "写它不生效;要强制 G11 别记 skipped 请用 --strict-gates"}}},
|
|
301
358
|
},
|
|
302
359
|
}
|
|
303
360
|
|
|
@@ -333,6 +390,12 @@ def _check(val, rule, path, rep, ctx):
|
|
|
333
390
|
return
|
|
334
391
|
|
|
335
392
|
enum = rule.get("enum")
|
|
393
|
+
# 惰性键:schema 与 filmcheck 都认它,但代码里没有任何读取点。
|
|
394
|
+
# 报 WARN 而不是 ERROR —— 老剧本里写了它不该变成硬失败;但也绝不能静默通过,
|
|
395
|
+
# 否则"写对了却不生效"会一路带到交付(本项目历史上最贵的一类缺陷)。
|
|
396
|
+
if rule.get("inert"):
|
|
397
|
+
rep.warn(path, "这个键**当前不生效**:%s" % rule["inert"], "inert_key")
|
|
398
|
+
|
|
336
399
|
if rule.get("enum_dynamic") == "style":
|
|
337
400
|
styles = ctx.get("styles")
|
|
338
401
|
if styles is None:
|
|
@@ -436,6 +499,13 @@ def _cue_ref(at):
|
|
|
436
499
|
float(off)
|
|
437
500
|
except ValueError:
|
|
438
501
|
return "bad", "偏移 %r 不是数字(写 l03+0.35 或 l03-0.2)" % off
|
|
502
|
+
# v0.8 词锚点:<id>:w<N>[+/-秒]。把 ":wN" 剥掉再做 id 存在性检查,
|
|
503
|
+
# 否则合法写法会被报成"场景 id 不存在"(假失败比不校验更糟)。
|
|
504
|
+
if ":w" in sid:
|
|
505
|
+
head, _sep, tail = sid.partition(":w")
|
|
506
|
+
if not tail.isdigit() or int(tail) < 1:
|
|
507
|
+
return "bad", "词锚点要写成 l03:w2(词序号从 1 开始),收到 %r" % sid
|
|
508
|
+
return "id", head
|
|
439
509
|
return "id", sid
|
|
440
510
|
|
|
441
511
|
|
|
@@ -468,6 +538,65 @@ def check_cross(script, script_path, rep, ctx):
|
|
|
468
538
|
ids.append(lid)
|
|
469
539
|
idset = set(ids)
|
|
470
540
|
|
|
541
|
+
# ---- lines[].persona / lines[].tone 拼错会静默回落:枚举从 voicekit 单向读取 ----
|
|
542
|
+
personas, tones = _load_personas()
|
|
543
|
+
if personas:
|
|
544
|
+
for i, ln in enumerate(lines):
|
|
545
|
+
raw = ln.get("persona")
|
|
546
|
+
if raw in (None, ""):
|
|
547
|
+
continue
|
|
548
|
+
name = str(raw).strip()
|
|
549
|
+
# 原始 edge-tts voice id(zh-CN-YunxiNeural)也允许写在 persona 位
|
|
550
|
+
if name in personas or ("-" in name and name.endswith("Neural")):
|
|
551
|
+
continue
|
|
552
|
+
near = _suggest(name, personas)
|
|
553
|
+
rep.warn("lines[%d].persona" % i,
|
|
554
|
+
"未知 persona %r —— voicekit 会**静默回落**到 kind 默认声线"
|
|
555
|
+
"(成片换人,且不会有任何报错)%s。可用:%s"
|
|
556
|
+
% (name, (";是不是想写 %r?" % near) if near else "",
|
|
557
|
+
" / ".join(personas[:9])), "unknown_persona")
|
|
558
|
+
# tone 不在这里查:LINE_SPEC 里已经有 enum(KINDS 与 voicekit.TONE_IDS 是同一组 6 个),
|
|
559
|
+
# 那是 ERROR 而不是 WARN。这里只补 persona —— 它没有静态枚举,拼错会静默换声线。
|
|
560
|
+
_ = tones
|
|
561
|
+
|
|
562
|
+
# ---- lines[].tail 当前不生效(保留字段):写了会静默无效,必须说出来 ----
|
|
563
|
+
for i, ln in enumerate(lines):
|
|
564
|
+
if _f(ln.get("tail", 0.0)) > 0:
|
|
565
|
+
lid = ln.get("id") or ("l%02d" % (i + 1))
|
|
566
|
+
rep.warn("lines[%d].tail" % i, (
|
|
567
|
+
"film.json 的逐句 tail 目前**不生效**:filmkit.build_timeline 只读 "
|
|
568
|
+
"tail_map,不读 lines[].tail(渲染跑得通,但句后留白没变)。要逐句"
|
|
569
|
+
"控制句后留白,请写顶层 tail_map(形如 l03: 1.4),或交给 voicekit 的 "
|
|
570
|
+
"voice_plan(--stage tts 落 work/voice_plan.json,st_layout 会并进 "
|
|
571
|
+
"tail_map)。例如 %s: 1.4" % lid),
|
|
572
|
+
"inert_line_tail")
|
|
573
|
+
|
|
574
|
+
# ---- v0.8 video 块:只支持 16:9 横屏 + 下采样输出 ----
|
|
575
|
+
# **不要在这里重写一遍规则**:允许列表 / 宽高比 / 上采样拒绝的权威实现在
|
|
576
|
+
# filmkit.video_plan()。以前这里是第二套手写判据,于是"1600x900"能过 filmcheck
|
|
577
|
+
# (0 error 0 warning)却在 render 的 st_layout 被 video_plan 拒片——校验器放行、
|
|
578
|
+
# 渲染器拒收,用户只会觉得门禁在骗人。这里改成直接调用权威实现,只做错误翻译。
|
|
579
|
+
vv = script.get("video") if isinstance(script.get("video"), dict) else None
|
|
580
|
+
if vv:
|
|
581
|
+
try:
|
|
582
|
+
import filmkit as _K
|
|
583
|
+
except Exception: # noqa: BLE001
|
|
584
|
+
_K = None
|
|
585
|
+
if _K is not None:
|
|
586
|
+
try:
|
|
587
|
+
_K.video_plan(script)
|
|
588
|
+
except ValueError as e:
|
|
589
|
+
rep.err("video", "%s(filmkit.video_plan 会在渲染时拒片,这里提前拦)" % e,
|
|
590
|
+
"bad_video")
|
|
591
|
+
except Exception as e: # noqa: BLE001
|
|
592
|
+
rep.err("video", "画幅解析失败:%s" % e, "bad_video")
|
|
593
|
+
else:
|
|
594
|
+
# filmkit 不可用(拷出来只带了 filmcheck)时退回最小校验:只查 fps 枚举,
|
|
595
|
+
# 并明说"完整校验需要 filmkit.py",避免让人以为已经全查过。
|
|
596
|
+
rep.warn("video", "找不到 filmkit.py:只做了 fps 枚举校验,"
|
|
597
|
+
"画幅/上采样规则未校验(完整校验请把 filmkit.py 放在同目录)",
|
|
598
|
+
"video_partial")
|
|
599
|
+
|
|
471
600
|
# ---- sfx.cues[].at 引用的场景 id 必须在 lines 里 ----
|
|
472
601
|
sfx = script.get("sfx") if isinstance(script.get("sfx"), dict) else {}
|
|
473
602
|
cues = sfx.get("cues") if isinstance(sfx.get("cues"), list) else []
|
|
@@ -497,23 +626,63 @@ def check_cross(script, script_path, rep, ctx):
|
|
|
497
626
|
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as e:
|
|
498
627
|
rep.warn(str(tl_path), "读不了 timeline(%s),跳过时长对账" % e, "timeline_unreadable")
|
|
499
628
|
return
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
629
|
+
# 节拍覆盖的优先级与 stylekit.resolve_script_style 一致:pacing > 顶层字段 > 默认。
|
|
630
|
+
# 不读 pacing 会把"pacing.lead=2.0"当成"没写 lead",估出来的时长偏小一半 -> 假 WARN。
|
|
631
|
+
pace = script.get("pacing") if isinstance(script.get("pacing"), dict) else {}
|
|
632
|
+
|
|
633
|
+
def _pace(key, default):
|
|
634
|
+
for box in (pace, script):
|
|
635
|
+
v = box.get(key) if isinstance(box, dict) else None
|
|
636
|
+
if v is not None:
|
|
637
|
+
return _f(v, default)
|
|
638
|
+
return float(default)
|
|
639
|
+
|
|
640
|
+
lead = _pace("lead", DEFAULT_LEAD)
|
|
641
|
+
tail = _pace("tail", DEFAULT_TAIL)
|
|
642
|
+
min_dur = _pace("min_dur", DEFAULT_MIN_DUR)
|
|
643
|
+
ghold = _pace("hold", 0.0)
|
|
644
|
+
holds, speech, est_lo, est_hi = 0.0, 0.0, 0.0, 0.0
|
|
505
645
|
for ln in lines:
|
|
506
|
-
|
|
646
|
+
chars = _weighted_chars(ln.get("text"))
|
|
507
647
|
hold = _f(ln.get("hold", ghold))
|
|
508
648
|
holds += hold
|
|
509
|
-
speech +=
|
|
510
|
-
|
|
511
|
-
|
|
649
|
+
speech += chars / CPS
|
|
650
|
+
# 快语速 = 时长短(下界),慢语速 = 时长长(上界)
|
|
651
|
+
est_lo += max(min_dur, lead + chars / CPS_HI + tail + hold)
|
|
652
|
+
est_hi += max(min_dur, lead + chars / CPS_LO + tail + hold)
|
|
653
|
+
# bar > 0 把每段向上取整到小节倍数,total 天然比逐句估算大;这时不拿估算对账。
|
|
654
|
+
drift = 0.0
|
|
655
|
+
if not (_f(script.get("bar", 0.0)) > 0):
|
|
656
|
+
if total < est_lo - TIMELINE_TOL:
|
|
657
|
+
drift = total - est_lo
|
|
658
|
+
elif total > est_hi + TIMELINE_TOL:
|
|
659
|
+
drift = total - est_hi
|
|
660
|
+
if drift:
|
|
512
661
|
rep.warn(str(tl_path),
|
|
513
|
-
"剧本 hold 合计 %.2f s + 语音预估 %.2f s(含 lead/tail)≈ 全片
|
|
514
|
-
"
|
|
662
|
+
"剧本 hold 合计 %.2f s + 语音预估 %.2f–%.2f s(含 lead/tail)≈ 全片 "
|
|
663
|
+
"%.2f–%.2f s,与 timeline 的 total=%.2f s 差 %.2f s > %.1f s。"
|
|
515
664
|
"改过台词 / hold / 节奏参数就重跑 --stage layout(别拿旧时间轴直接渲)"
|
|
516
|
-
% (holds,
|
|
665
|
+
% (holds, est_lo, est_hi, est_lo, est_hi, total,
|
|
666
|
+
abs(drift), TIMELINE_TOL), "timeline_drift")
|
|
667
|
+
# 台词内容逐句对账(最可靠的一条:build_timeline 把整条 line 原样拷进 scene,
|
|
668
|
+
# 所以 timeline.scenes[i].text 就是"上次排轴时那句话")。改过台词没重跑 layout,
|
|
669
|
+
# 这里能精确点出来;比按时长估算准得多,也不会被语速/语气差异干扰。
|
|
670
|
+
tl_by_id = {str(s.get("id")): s for s in (tl.get("scenes") or []) if isinstance(s, dict)}
|
|
671
|
+
text_drift = []
|
|
672
|
+
for ln in lines:
|
|
673
|
+
lid = ln.get("id")
|
|
674
|
+
if lid is None or not str(lid).strip():
|
|
675
|
+
continue
|
|
676
|
+
scene = tl_by_id.get(str(lid))
|
|
677
|
+
if not isinstance(scene, dict) or "text" not in scene:
|
|
678
|
+
continue # 老版本 timeline 不带 text:跳过
|
|
679
|
+
if str(scene.get("text")) != str(ln.get("text")):
|
|
680
|
+
text_drift.append(str(lid))
|
|
681
|
+
if text_drift:
|
|
682
|
+
rep.warn(str(tl_path),
|
|
683
|
+
"timeline 里 %d 句的台词与 film.json 不一致(%s):TTS 与字幕都还是旧词,"
|
|
684
|
+
"改完台词必须重跑 --stage tts layout 再渲"
|
|
685
|
+
% (len(text_drift), ", ".join(text_drift[:6])), "timeline_text")
|
|
517
686
|
tl_ids = [str(s.get("id")) for s in (tl.get("scenes") or []) if isinstance(s, dict)]
|
|
518
687
|
if idset and tl_ids and set(tl_ids) != idset:
|
|
519
688
|
miss = sorted(idset - set(tl_ids))
|
|
@@ -560,6 +729,27 @@ def _load_styles():
|
|
|
560
729
|
return None
|
|
561
730
|
|
|
562
731
|
|
|
732
|
+
def _load_personas():
|
|
733
|
+
"""从 voicekit 单向读取 persona 名与 tone id;拿不到返回 (None, None) 降级成不查。
|
|
734
|
+
|
|
735
|
+
为什么要查:voicekit 对写错的 persona/tone 是**静默回落**(reason 里留
|
|
736
|
+
unknown_persona:xxx,但不报错、不告警),于是"拼错一个声线名"的后果是整片
|
|
737
|
+
静默换成 kind 默认声线——听感变了,却没有任何一处提示。schema 层只校验 t=str,
|
|
738
|
+
拦不住;这里补上(规则仍然只有一份:枚举从 voicekit 读,不复制)。
|
|
739
|
+
"""
|
|
740
|
+
try:
|
|
741
|
+
if str(HERE) not in sys.path:
|
|
742
|
+
sys.path.insert(0, str(HERE))
|
|
743
|
+
import voicekit # noqa: F401 (只读枚举,不复制)
|
|
744
|
+
personas = list(getattr(voicekit, "PERSONA_IDS", ()) or
|
|
745
|
+
getattr(voicekit, "VOICE_PERSONAS", {}).keys())
|
|
746
|
+
tones = list(getattr(voicekit, "TONE_IDS", ()) or
|
|
747
|
+
getattr(voicekit, "VOICE_TONES", {}).keys())
|
|
748
|
+
return (personas or None), (tones or None)
|
|
749
|
+
except Exception: # noqa: BLE001
|
|
750
|
+
return None, None
|
|
751
|
+
|
|
752
|
+
|
|
563
753
|
def _classify(data):
|
|
564
754
|
"""film = 像剧本;artifact = 其他产物(配乐报告等),跳过;unknown = 判不了。"""
|
|
565
755
|
if not isinstance(data, dict):
|