@dsh-gis/dsh-video-studio-skill 0.9.1 → 1.0.4
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 +473 -0
- package/README.md +263 -327
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/package.json +9 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +31 -42
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1216 -1123
- package/skills/video-studio/assets/design_fixture.json +141 -0
- package/skills/video-studio/assets/film.json +2 -4
- package/skills/video-studio/assets/golden_frames.json +13 -10
- package/skills/video-studio/assets/scenes.py +153 -65
- package/skills/video-studio/assets/voice_loudness.json +16 -0
- package/skills/video-studio/references/appearance.md +123 -0
- package/skills/video-studio/references/audio-variation.md +74 -93
- package/skills/video-studio/references/audio.md +64 -66
- package/skills/video-studio/references/change-impact.md +5 -5
- package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +16 -18
- package/skills/video-studio/references/frame-qa.md +48 -16
- package/skills/video-studio/references/pipeline.md +4 -4
- package/skills/video-studio/references/pitfalls.md +22 -3
- package/skills/video-studio/references/proposal.md +160 -0
- package/skills/video-studio/references/quality-gates.md +11 -11
- package/skills/video-studio/references/scene-authoring.md +2 -2
- package/skills/video-studio/references/scene-grammar.md +40 -42
- package/skills/video-studio/references/sfx-design.md +8 -6
- package/skills/video-studio/references/web-engine.md +8 -8
- package/skills/video-studio/references/word-timeline.md +9 -9
- package/skills/video-studio/requirements.txt +9 -13
- package/skills/video-studio/scripts/designkit.py +2115 -0
- package/skills/video-studio/scripts/filmcheck.py +22 -48
- package/skills/video-studio/scripts/filmkit.py +2980 -2904
- package/skills/video-studio/scripts/gates.py +4 -4
- package/skills/video-studio/scripts/grammar.py +73 -13
- package/skills/video-studio/scripts/musicgen.py +2042 -0
- package/skills/video-studio/scripts/preflight.py +14 -7
- package/skills/video-studio/scripts/previewkit.py +848 -0
- package/skills/video-studio/scripts/propose.py +825 -0
- package/skills/video-studio/scripts/qa_frames.py +73 -39
- package/skills/video-studio/scripts/qaasr.py +26 -7
- package/skills/video-studio/scripts/qavmaf.py +89 -23
- package/skills/video-studio/scripts/render.py +545 -231
- package/skills/video-studio/scripts/scorekit.py +55 -1755
- package/skills/video-studio/scripts/selfcheck.py +815 -215
- package/skills/video-studio/scripts/sfxkit.py +27 -26
- package/skills/video-studio/scripts/stagewatch.py +24 -8
- package/skills/video-studio/scripts/stylekit.py +207 -888
- package/skills/video-studio/scripts/voicekit.py +508 -387
- package/skills/video-studio/scripts/webengine.py +71 -72
- package/tools/audio_similarity.py +64 -37
- package/tools/ci_check.py +20 -14
- package/tools/estimate.py +34 -29
- package/tools/golden_frames.py +72 -28
- package/tools/silhouette_qa.py +520 -168
- package/docs/demo-pil-sheet.png +0 -0
- package/docs/styles/demo-results.json +0 -76
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-before-after.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/skills/video-studio/assets/score_demo.json +0 -307
- package/skills/video-studio/references/style-packs.md +0 -283
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""qa_frames.py — 帧级 /
|
|
2
|
+
"""qa_frames.py — 帧级 / 时间轴级深度质检。
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
只看容器、响度、黑帧、锐度的容器级质检看不出"画面内部"的问题:
|
|
5
5
|
一帧白闪、一段近黑空窗、音效 cue 与视觉事件没对上——这些只有逐帧解码才能发现。
|
|
6
6
|
|
|
7
7
|
本模块提供:
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
4. luma_curve / loudness_curve:亮度与响度时间曲线(响度是近似值,见文档)。
|
|
13
13
|
5. static_warnings("_static_warnings",报告里进 warnings):画面长时间"完全没有运动"
|
|
14
14
|
(卡帧 / 忘了加动画)。判据是"最活跃的 8x8 块在 0.5 s 里是否变了 > 1.5 级灰度",
|
|
15
|
-
连续 6 s
|
|
16
|
-
|
|
17
|
-
6. sync_check:cues.json 的音频提示点与视觉事件配对,报 dt
|
|
15
|
+
连续 6 s 全部不动才告警(不用"全帧平均亮度 3 s 波动 < 0.002"这类判据:它对逐字
|
|
16
|
+
入场这类局部动画不敏感,自带模板片会误报 4 段)。
|
|
17
|
+
6. sync_check:cues.json 的音频提示点与视觉事件配对,报 dt 超容差项。支持
|
|
18
18
|
cue 自带 visual_at / visual_t 画面锚点(PIL 路线没有 window.getEvents() 也能自校验),
|
|
19
19
|
并可用 require_sync(环境变量 QA_REQUIRE_SYNC=1 / CLI --require-sync)把
|
|
20
20
|
"同步门禁没执行"从 warning 升级为 error。
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
(亮度曲线 + 响度曲线 + 闪烁点 + 空窗区 + 同步偏差,一眼看出问题位置)。
|
|
23
23
|
|
|
24
24
|
判据与阈值来自 ai-history-film(MIT)的实测经验:
|
|
25
|
-
-
|
|
25
|
+
- 开发日志第十四轮"单帧闪烁":逐帧解码成 160x90、每帧减去前后两帧平均、分块统计,
|
|
26
26
|
找"只亮一帧"的格子。实测真实异常 10–15,正常运动画面 ≤ 6.4,因此默认阈值 6.0。
|
|
27
27
|
- 第五轮"画面空窗":逐帧亮度扫出 ≥ 1 s 近黑片段(保留片头、片尾等刻意黑)。
|
|
28
28
|
- video/qa_sync.js:画面视觉事件(闪白/震屏/冲击环)与音频 cue 对照。
|
|
@@ -39,7 +39,7 @@ FILMKIT_FFMPEG)。正常 import 不做重活、不打印。
|
|
|
39
39
|
"""
|
|
40
40
|
from __future__ import annotations
|
|
41
41
|
|
|
42
|
-
__version__ = "0.
|
|
42
|
+
__version__ = "1.0.4"
|
|
43
43
|
|
|
44
44
|
import argparse
|
|
45
45
|
import json
|
|
@@ -81,6 +81,10 @@ FLICKER_ISO = 1.5 # 孤立性比值:当前帧异常度 >= 1.5 x 前后
|
|
|
81
81
|
# "回到原位"闸门:|F[t+1]-F[t-1]| 必须小于 1.6 x 中间帧偏离量。
|
|
82
82
|
# 单帧闪烁 ≈ 0(前后几乎相同);加速运动 ≈ 2 以上(前后差更多)→ 排除。
|
|
83
83
|
FLICKER_RETURN_RATIO = 1.6
|
|
84
|
+
# 符号一致度下限:块内像素"同向"变化的比例。真闪白/闪黑整块同向(0.75–1.0);
|
|
85
|
+
# 文字入场 / 母题动画 / 镜头缓推是有增有减的纹理变化(实测 0.42)。
|
|
86
|
+
# 低于此值不判 error(降级 warning 供人复核)——否则零拷贝首片会被 G9 误杀。
|
|
87
|
+
FLICKER_AGREE_MIN = 0.60
|
|
84
88
|
# 与已登记视觉事件(闪白/震屏/冲击环)重合多久内视为"设计好的重击",不计 error
|
|
85
89
|
FLICKER_EXPLAIN_WIN = 0.2
|
|
86
90
|
# 转化为"可解释设计事件"的 event.kind(小写匹配;cues.json §4.2 的 events 由页面导出)
|
|
@@ -98,18 +102,17 @@ TAIL_SKIP = 1.2 # 片尾多少秒内不算(刻意淡出)
|
|
|
98
102
|
# 音画同步
|
|
99
103
|
SYNC_TOL = 0.06 # dt 容差(秒)
|
|
100
104
|
SYNC_SAME_WIN = 2.0 # 同 kind 配对的最大搜索窗(秒)
|
|
101
|
-
# 异 kind 的"就近配对"
|
|
105
|
+
# 异 kind 的"就近配对"只用于**确认同步**(|dt| <= tol 才认可),没有独立的搜索窗;
|
|
102
106
|
# 这个常量保留给"events 每项带可选 cue 字段、允许跨 kind 显式配对"的下一版(CONTRACT §5.4 兼容位)。
|
|
103
107
|
SYNC_NEAR_WIN = 0.5
|
|
104
108
|
|
|
105
|
-
# 画面"长时间静止"警告(可能卡帧 /
|
|
109
|
+
# 画面"长时间静止"警告(可能卡帧 / 忘了加动画)
|
|
106
110
|
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
# (411/814 个窗口命中),而画面一直在动——这是本轮修掉的假阳性。
|
|
111
|
+
# 不用"全帧平均亮度 3 s 滑动窗内波动 < 0.002"这类判据:它对**局部动画**天生不敏感,
|
|
112
|
+
# 逐字入场、数字滚动、卡片切换只占全屏几个百分点,平均亮度几乎不动。实测自带模板片
|
|
113
|
+
# (30.10 s)会因此报出 4 段假"亮度长时间无变化"(411/814 个窗口命中),而画面一直在动。
|
|
111
114
|
#
|
|
112
|
-
#
|
|
115
|
+
# 本判据看"画面里最活跃的 8x8 块,在 0.5 s 里变了多少级灰度":
|
|
113
116
|
# drift[t] = max over 8x8 blocks of mean |F[t] - F[t-lag]| * 255, lag = 0.5 s * fps
|
|
114
117
|
# 物理含义:把画面切成 8x8 块(160x90 下共 20x11 块,单块占全屏 0.53%),取
|
|
115
118
|
# "0.5 秒内变化最大的那一块的平均变化量"(单位:灰度级 0–255)。
|
|
@@ -366,11 +369,16 @@ def sample_frames(path, w=160, h=90, from_s=0.0, to_s=None) -> np.ndarray:
|
|
|
366
369
|
return np.stack(frames, axis=0)
|
|
367
370
|
|
|
368
371
|
|
|
369
|
-
def _block_max(diff, block) -> tuple
|
|
370
|
-
"""把 (h,w) 差异图按 block×block 分块取均值最大,返回 (最大均值, bbox,
|
|
372
|
+
def _block_max(diff, block, signed=None) -> tuple:
|
|
373
|
+
"""把 (h,w) 差异图按 block×block 分块取均值最大,返回 (最大均值, bbox, 该块中值, 符号一致度)。
|
|
371
374
|
|
|
372
375
|
中值是复核线索:整块一起变亮(真单帧事件)时中值也高;只有小块元素在动时
|
|
373
376
|
均值被少数像素拉高、中值仍接近 0(见 references/frame-qa.md 的 16.933 s 案例)。
|
|
377
|
+
|
|
378
|
+
符号一致度(signed 传带符号的二阶差分时才算):整块**同向**变亮/变暗
|
|
379
|
+
= 真闪白/闪黑;文字逐字入场、母题动画、镜头缓推产生的只是**有增有减**的纹理变化,
|
|
380
|
+
符号一致度低。实测(零拷贝首片):t=5.867s 被误报的块 score 51.5 / median 44.5,
|
|
381
|
+
但符号一致度只有 0.42;同一位置人为注入 +50 灰闪白后一致度 0.75。
|
|
374
382
|
"""
|
|
375
383
|
h, w = diff.shape
|
|
376
384
|
b = max(1, int(block))
|
|
@@ -387,7 +395,14 @@ def _block_max(diff, block) -> tuple[float, tuple[int, int, int, int], float]:
|
|
|
387
395
|
bb = (x0, y0, x1, y1)
|
|
388
396
|
blk = diff[y0:y1, x0:x1]
|
|
389
397
|
med = float(np.median(blk)) if blk.size else 0.0
|
|
390
|
-
|
|
398
|
+
agree = None
|
|
399
|
+
if isinstance(signed, np.ndarray) and signed.shape == diff.shape:
|
|
400
|
+
sblk = signed[y0:y1, x0:x1].astype(np.float64)
|
|
401
|
+
if sblk.size:
|
|
402
|
+
m = float(sblk.mean())
|
|
403
|
+
# 与"块内平均方向"同向的像素占比:整块同向变亮 ≈ 1.0,纹理/运动互有增减 ≈ 0.5
|
|
404
|
+
agree = float(np.mean(np.sign(sblk) == np.sign(m))) if m != 0 else 0.0
|
|
405
|
+
return float(means[r, c]), bb, med, agree
|
|
391
406
|
|
|
392
407
|
|
|
393
408
|
def _block_peak(diff, block) -> float:
|
|
@@ -434,10 +449,11 @@ def _scan_full(path, w=SAMPLE_W, h=SAMPLE_H, block=FLICKER_BLOCK, fps=None):
|
|
|
434
449
|
if len(history) > lag:
|
|
435
450
|
history.pop(0)
|
|
436
451
|
if prev2 is not None:
|
|
437
|
-
|
|
438
|
-
|
|
452
|
+
signed = (prev1 - (prev2 + fr) * 0.5) * 255.0
|
|
453
|
+
diff = np.abs(signed)
|
|
454
|
+
s, bb, med, agree = _block_max(diff, block, signed=signed)
|
|
439
455
|
scores.append(float(s))
|
|
440
|
-
boxes.append((bb, med))
|
|
456
|
+
boxes.append((bb, med, agree))
|
|
441
457
|
# 同一个块上算"前后两帧彼此差多少",用于区分闪烁与加速运动
|
|
442
458
|
if bb:
|
|
443
459
|
x0, y0, x1, y1 = bb
|
|
@@ -502,7 +518,9 @@ def _flicker_from_scores(scores, boxes, fps, thresh, returns=None, suppressed=No
|
|
|
502
518
|
continue
|
|
503
519
|
if s < FLICKER_ISO * max(p, q):
|
|
504
520
|
continue
|
|
505
|
-
|
|
521
|
+
box = boxes[t - 1] if 0 <= t - 1 < len(boxes) else ((0, 0, 0, 0), 0.0, None)
|
|
522
|
+
bb, med = box[0], box[1]
|
|
523
|
+
agree = box[2] if len(box) > 2 else None
|
|
506
524
|
r = None
|
|
507
525
|
if returns is not None and t < len(returns):
|
|
508
526
|
r = float(returns[t])
|
|
@@ -522,6 +540,7 @@ def _flicker_from_scores(scores, boxes, fps, thresh, returns=None, suppressed=No
|
|
|
522
540
|
"bbox": [int(v) for v in bb],
|
|
523
541
|
"ratio": round(s / max(p, q, 1e-9), 2),
|
|
524
542
|
"median": round(float(med), 2),
|
|
543
|
+
**({"agree": round(float(agree), 2)} if agree is not None else {}),
|
|
525
544
|
**({"return": round(r, 2)} if r is not None and math.isfinite(r) else {})})
|
|
526
545
|
return out
|
|
527
546
|
|
|
@@ -640,10 +659,10 @@ def _src_of(obj) -> str:
|
|
|
640
659
|
def _src_compatible(cue, ev) -> bool:
|
|
641
660
|
"""两个条目能不能做同步配对:只有**双方都明确标了来源且不同**时才认为时间基准不同。
|
|
642
661
|
|
|
643
|
-
|
|
662
|
+
render.py 在 engine=web 时把三路 cue 合并(film.json / scenes.py / 页面),
|
|
644
663
|
其中 scenes.py 的 cue 按 PIL 时间轴、页面事件按页面时间轴——跨基准配对会报假 FAIL
|
|
645
664
|
(实测:cue ui_blip @7.725s 配 页面事件 @9.550s,dt=+1.825s)。
|
|
646
|
-
没标 src 的条目(手写 events /
|
|
665
|
+
没标 src 的条目(手写 events / 缺 src 的 cues.json)一律视作兼容,保持向后兼容。
|
|
647
666
|
"""
|
|
648
667
|
cs, es = _src_of(cue), _src_of(ev)
|
|
649
668
|
return not (cs and es and cs != es)
|
|
@@ -656,7 +675,7 @@ def _compatible_events(cue, events) -> list:
|
|
|
656
675
|
def _match_cue(cue, events, tol) -> tuple[dict | None, float | None, str]:
|
|
657
676
|
"""给一个 cue 找对应视觉事件。
|
|
658
677
|
|
|
659
|
-
|
|
678
|
+
规则:
|
|
660
679
|
1. 同 kind:kind == cue.type,搜索窗 SYNC_SAME_WIN(2 s);
|
|
661
680
|
2. 异 kind:只取时间最近者,且**只有 |dt| <= tol 时才认可**(用作"确实同步了"的佐证)。
|
|
662
681
|
超过容差就不配对——跨 kind 的"最近事件"不是"预期事件",据此判超差会制造假 FAIL。
|
|
@@ -695,10 +714,10 @@ def sync_check(cues, events, tol=SYNC_TOL) -> list[dict]:
|
|
|
695
714
|
|
|
696
715
|
返回 [{"cue","cue_t","event_t","dt"}],dt = event_t - cue_t(秒,3 位小数)。
|
|
697
716
|
配对规则(按优先级):
|
|
698
|
-
0. **cue
|
|
717
|
+
0. **cue 自带视觉锚点**:cue 里写了数值 visual_at / visual_t 时,
|
|
699
718
|
直接拿它当"这条音效对应的画面时刻"自校验,不需要 events。
|
|
700
719
|
这条是给 PIL 路线补的口子——pil 引擎没有 window.getEvents(),events 恒空,
|
|
701
|
-
|
|
720
|
+
没有这条口子 sync 检查在 PIL 路线**永远是空的**,但 SKILL.md §7 把"
|
|
702
721
|
dt ≤ 60 ms"写成通用门禁(文档承诺、代码没做到)。
|
|
703
722
|
用法:scenes.py 的 cues() 钩子里返回
|
|
704
723
|
{"at": "l03+0.35", "type": "impact", "visual_at": "abs:12.42"}
|
|
@@ -708,7 +727,7 @@ def sync_check(cues, events, tol=SYNC_TOL) -> list[dict]:
|
|
|
708
727
|
超过容差就不配对——跨 kind 的"最近事件"不是"预期事件",据此判超差会制造假 FAIL。
|
|
709
728
|
配不上的 cue 视为"没有对应视觉事件",不判超差(很多音效本来就没有画面事件)。
|
|
710
729
|
⚠ 命名约定见 references/frame-qa.md §2.5:events 与 cue 不同名时本检查会静默全过。
|
|
711
|
-
⚠ 来源(src
|
|
730
|
+
⚠ 来源(src)过滤:cue 与事件**双方都标了 src 且不同**时不配对——web 引擎
|
|
712
731
|
三路合并时 PIL 时间轴的 cue 与页面事件基准不同,跨基准配对会报假 FAIL。跳过数量由
|
|
713
732
|
_sync_coverage().src_skipped 计数并在 _load_cues 里转成 warning(不静默)。
|
|
714
733
|
"""
|
|
@@ -852,7 +871,7 @@ def _load_cues(cues_json):
|
|
|
852
871
|
cues = [c for c in (data.get("cues") or []) if isinstance(c, dict)]
|
|
853
872
|
raw_events = [e for e in (data.get("events") or []) if isinstance(e, dict)]
|
|
854
873
|
# 只认"带有限数值 t"的事件:sync_check 内部本来就这么过滤(e.get("t") is not None),
|
|
855
|
-
# 但 _sync_coverage /
|
|
874
|
+
# 但 _sync_coverage / 命名约定守卫必须用**过滤后**的列表 —— 否则 events 里只要有
|
|
856
875
|
# {"kind": "impact"} 这种缺 t 的条目,judged 就会被算成 1,命名约定守卫也被骗过,
|
|
857
876
|
# 结果是"门禁看起来在查、实际一条都没判"且不报警(实测:cues=1/events=[{kind:impact}]
|
|
858
877
|
# -> coverage judged=1,report errors=[] warnings=[])。
|
|
@@ -963,7 +982,7 @@ def _visual_event_times(events) -> list[float]:
|
|
|
963
982
|
def _static_segments(drift, fps, blacks=None) -> list[tuple[float, float]]:
|
|
964
983
|
"""画面长时间"完全没有运动"(卡帧 / 忘了加动画)-> [(t0, t1)] 秒区间。
|
|
965
984
|
|
|
966
|
-
|
|
985
|
+
判据(不用"全帧平均亮度 3 s 波动 < 0.002",理由见下):
|
|
967
986
|
对每个窗口起点 i,看 (i+lag, i+w] 这 w-lag 个 drift 值(drift[t] = "t 与 t-lag
|
|
968
987
|
之间最活跃的 8x8 块平均变了多少级灰度")是否都 <= FLAT_DRIFT_EPS。
|
|
969
988
|
全部命中 → 这 6 秒里画面里任何一个 8x8 块在任意 0.5 s 内都没动过,人眼就是一张定格。
|
|
@@ -1019,7 +1038,7 @@ def _static_warnings(drift, fps, blacks=None) -> list[str]:
|
|
|
1019
1038
|
for t0, t1 in segs:
|
|
1020
1039
|
out.append(
|
|
1021
1040
|
"画面长时间静止(可能卡帧/忘了加动画):%.2f–%.2fs(连续 %.1fs 内最活跃的 8x8 块"
|
|
1022
|
-
"在任意 %.1f s 里的变化都 < %.1f 级灰度;判据见 references/frame-qa.md §2.
|
|
1041
|
+
"在任意 %.1f s 里的变化都 < %.1f 级灰度;判据见 references/frame-qa.md §2.4b)"
|
|
1023
1042
|
% (t0, t1, t1 - t0, FLAT_LAG_S, FLAT_DRIFT_EPS))
|
|
1024
1043
|
return out
|
|
1025
1044
|
|
|
@@ -1042,7 +1061,7 @@ def report(film, *, cues_json=None, expect_duration=None, out_json=None, out_png
|
|
|
1042
1061
|
cues_json 可以是路径或已载入的 dict;events 里闪白/震屏/冲击环时刻会用来排除
|
|
1043
1062
|
"设计好的重击"(记 warning,不记 error)。
|
|
1044
1063
|
|
|
1045
|
-
require_sync
|
|
1064
|
+
require_sync:把"音画同步门禁没能执行"从 warning 升级为 error。
|
|
1046
1065
|
默认 None = 读环境变量 QA_REQUIRE_SYNC(1/true/yes/on 时开启),便于不改进 render.py
|
|
1047
1066
|
就整体开闸(render.py 的 st_qa 调的正是本函数)。
|
|
1048
1067
|
背景:SKILL.md §7 把"音画同步 dt ≤ 60 ms"写成通用门禁,但 --engine pil 时 cues.json
|
|
@@ -1079,13 +1098,28 @@ def report(film, *, cues_json=None, expect_duration=None, out_json=None, out_png
|
|
|
1079
1098
|
fl["expected"] = any(abs(fl["t"] - te) <= FLICKER_EXPLAIN_WIN for te in explained_times)
|
|
1080
1099
|
|
|
1081
1100
|
errors = []
|
|
1101
|
+
textured = []
|
|
1082
1102
|
for fl in flicker:
|
|
1083
1103
|
if fl.get("expected"):
|
|
1084
1104
|
continue
|
|
1085
1105
|
bb = fl.get("bbox") or [0, 0, 0, 0]
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1106
|
+
msg = ("t=%.3fs score=%.1f(孤立比值 %.2f,块中值 %.2f,异常块 %d,%d–%d,%d,阈值 %.1f)"
|
|
1107
|
+
% (fl["t"], fl["score"], fl.get("ratio", 0.0), fl.get("median", 0.0),
|
|
1108
|
+
bb[0], bb[1], bb[2], bb[3], FLICKER_THRESH))
|
|
1109
|
+
# 符号一致度 = 块内像素是否**同向**变亮/变暗。
|
|
1110
|
+
# 真闪白/闪黑整块同向(实测 0.75–1.0);文字逐字入场、母题动画、镜头缓推
|
|
1111
|
+
# 是"有增有减"的纹理变化(实测 0.42)。不降级的后果是首片就 fail 在 G9
|
|
1112
|
+
# (零拷贝自测实测:t=5.867s 标题逐字入场被误判)。
|
|
1113
|
+
# 处理:低于阈值不静默放过,降级为 warning 并保留全部数值,交给人工复核。
|
|
1114
|
+
fl["sign_agree"] = fl.get("agree")
|
|
1115
|
+
if fl.get("agree") is not None and float(fl["agree"]) < FLICKER_AGREE_MIN:
|
|
1116
|
+
textured.append(msg + (",符号一致度 %.2f < %.2f → 判为纹理/动画扰动"
|
|
1117
|
+
% (float(fl["agree"]), FLICKER_AGREE_MIN)))
|
|
1118
|
+
continue
|
|
1119
|
+
errors.append("单帧闪烁 " + msg)
|
|
1120
|
+
for msg in textured:
|
|
1121
|
+
warnings.append("疑似非闪烁(块内像素有增有减,像是文字/母题动画):" + msg
|
|
1122
|
+
+ ";若画面确有单帧闪白,调低 FLICKER_AGREE_MIN 或登记设计事件")
|
|
1089
1123
|
explained = sum(1 for fl in flicker if fl.get("expected"))
|
|
1090
1124
|
if explained:
|
|
1091
1125
|
warnings.append("已排除 %d 处与已登记视觉事件(闪白/震屏/冲击环)重合的单帧闪烁;"
|
|
@@ -1147,7 +1181,7 @@ def report(film, *, cues_json=None, expect_duration=None, out_json=None, out_png
|
|
|
1147
1181
|
rep = {"flicker": flicker, "black_windows": blacks, "luma": luma, "loudness": loud,
|
|
1148
1182
|
"sync": sync, "errors": errors, "warnings": warnings,
|
|
1149
1183
|
# sync_info:同步门禁到底查了多少(render.py:1172 一直在读 deep["sync_info"]["judged"],
|
|
1150
|
-
#
|
|
1184
|
+
# 缺了这个键 QC 报告的 G9 就永远显示"同步未判")。
|
|
1151
1185
|
"sync_info": dict(sync_info)}
|
|
1152
1186
|
if out_json:
|
|
1153
1187
|
_write_json(rep, out_json)
|
|
@@ -1396,7 +1430,7 @@ def _draw_png(rep, film, fps, duration, events, out_png):
|
|
|
1396
1430
|
"重合(设计重击,不计 error)" % SYNC_TOL)
|
|
1397
1431
|
d.text((PLOT_ML, 788), foot1, fill=gray, font=f_small)
|
|
1398
1432
|
d.text((PLOT_ML, 808), foot2, fill=gray, font=f_small)
|
|
1399
|
-
d.text((W - PLOT_MR, 808), "qa_frames
|
|
1433
|
+
d.text((W - PLOT_MR, 808), "qa_frames", fill=(160, 163, 172), font=f_small,
|
|
1400
1434
|
anchor="ra")
|
|
1401
1435
|
|
|
1402
1436
|
out_png = str(out_png)
|
|
@@ -1854,8 +1888,8 @@ def selftest() -> int:
|
|
|
1854
1888
|
out_json=out_json, out_png=out_png)
|
|
1855
1889
|
keys = {"flicker", "black_windows", "luma", "loudness", "sync",
|
|
1856
1890
|
"errors", "warnings"}
|
|
1857
|
-
# sync_info
|
|
1858
|
-
# 读"同步到底判了几条"
|
|
1891
|
+
# sync_info 是报告里的**增量**字段(render.py 的 G9 从 deep["sync_info"]["judged"]
|
|
1892
|
+
# 读"同步到底判了几条",缺了它 -> QC 报告永远显示"同步未判")。
|
|
1859
1893
|
# 契约 §4.5 的 7 个键必须齐全,允许额外的只读统计键。
|
|
1860
1894
|
if not keys.issubset(set(rep)):
|
|
1861
1895
|
return False, "顶层字段 %s 缺 §4.5 键" % sorted(keys - set(rep))
|
|
@@ -1985,7 +2019,7 @@ def selftest() -> int:
|
|
|
1985
2019
|
def main(argv=None) -> int:
|
|
1986
2020
|
ap = argparse.ArgumentParser(
|
|
1987
2021
|
prog="qa_frames.py",
|
|
1988
|
-
description="帧级 /
|
|
2022
|
+
description="帧级 / 时间轴级深度质检:单帧闪烁、画面空窗、音画同步")
|
|
1989
2023
|
ap.add_argument("film", nargs="?", help="成片路径(.mp4 等)")
|
|
1990
2024
|
ap.add_argument("--cues", default=None,
|
|
1991
2025
|
help="work/cues.json(§4.2:cues 音频提示点 + events 视觉事件)")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""qaasr.py — ASR
|
|
2
|
+
"""qaasr.py — ASR 回读校验。
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
容器 / 响度 / 帧 / 音画同步这几层质检,都建立在一个假设上:
|
|
5
5
|
**成片音轨里念的就是剧本那几句话**。可是 TTS 缓存串了、换声线时漏了某句、
|
|
6
6
|
混音把某一段压没了、重新渲了但音轨是旧的——这些从容器和波形上都看不出来。
|
|
7
7
|
本模块把成片音轨转回文字,和剧本逐句对账:
|
|
@@ -66,7 +66,7 @@ tiny 只适合快速冒烟(错字多、时间戳只到整秒)。默认 model
|
|
|
66
66
|
"""
|
|
67
67
|
from __future__ import annotations
|
|
68
68
|
|
|
69
|
-
__version__ = "0.
|
|
69
|
+
__version__ = "1.0.4"
|
|
70
70
|
|
|
71
71
|
import argparse
|
|
72
72
|
import json
|
|
@@ -101,7 +101,7 @@ AUDIO_SR = 16000
|
|
|
101
101
|
OFFSET_WARN_S = 0.35
|
|
102
102
|
# 召回率的 pass / fail 线。**权威值在 render.py**(QA_ASR_RECALL_PASS / QA_ASR_RECALL_WARN,
|
|
103
103
|
# G12 门禁从这里读),本模块只是把默认值对齐,避免"同一部片、CLI 判 fail、门禁判 pass":
|
|
104
|
-
#
|
|
104
|
+
# 同一事实只能有一个权威值。
|
|
105
105
|
# 为什么是 0.85 / 0.72 而不是 0.95 / 0.90:中文旁白实测(模板片,faster-whisper small)
|
|
106
106
|
# 干净成片召回只有 0.81–0.83 —— 中英混排专名("DeepSeek 4.1 Flash"→DIXX 变体)是 ASR 的
|
|
107
107
|
# 正常误差,不是片子坏了;低于 0.72 才说明"整句丢了 / 音轨跟剧本不是一套"。
|
|
@@ -821,9 +821,13 @@ def _judge(scenes, segments, *, recall_pass=QA_RECALL_PASS, recall_warn=QA_RECAL
|
|
|
821
821
|
assigned = _assign_text(scenes, segments)
|
|
822
822
|
per_line, missing, offsets, offset_ref = [], [], {}, {}
|
|
823
823
|
tot_ref, tot_hit = 0, 0
|
|
824
|
+
lost_counter = Counter() # 全片"ref 有、ASR 没对上"的字符
|
|
824
825
|
for sc, got in zip(scenes, assigned):
|
|
825
826
|
ref = _norm_text(sc.get("text"))
|
|
826
827
|
hyp = _norm_text(got["text"])
|
|
828
|
+
lost = Counter(ref) - Counter(hyp) if ref else Counter()
|
|
829
|
+
if lost:
|
|
830
|
+
lost_counter.update(lost.elements())
|
|
827
831
|
hit = sum((Counter(ref) & Counter(hyp)).values()) if ref else 0
|
|
828
832
|
r = (hit / float(len(ref))) if ref else 1.0
|
|
829
833
|
tot_ref += len(ref)
|
|
@@ -833,6 +837,8 @@ def _judge(scenes, segments, *, recall_pass=QA_RECALL_PASS, recall_warn=QA_RECAL
|
|
|
833
837
|
"end": round(float(sc["end"]), 3), "ref_chars": len(ref),
|
|
834
838
|
"asr_chars": len(hyp), "hit_chars": hit, "recall": round(r, 4),
|
|
835
839
|
"asr_text": got["text"][:200],
|
|
840
|
+
# 单段掉分定位:这一段里"剧本有、ASR 没对上"的字(最多 6 个)
|
|
841
|
+
"lost": ["%s×%d" % (ch, n) for ch, n in lost.most_common(6)] if lost else [],
|
|
836
842
|
})
|
|
837
843
|
if ref and r < MISS_LINE_RECALL:
|
|
838
844
|
missing.append(sc["id"])
|
|
@@ -866,8 +872,21 @@ def _judge(scenes, segments, *, recall_pass=QA_RECALL_PASS, recall_warn=QA_RECAL
|
|
|
866
872
|
% (recall, recall_warn, tot_ref, tot_hit))
|
|
867
873
|
status = "fail"
|
|
868
874
|
elif recall < recall_pass - 1e-12:
|
|
875
|
+
# 只报"召回 0.813"没法修:实测掉分几乎全在中英混排专名("DeepSeek 4.1 Flash"
|
|
876
|
+
# 被听成 DIXX 之类)。这里把"剧本里有、成片里没对上"的字符按次数排出来,
|
|
877
|
+
# 让 agent 知道该改哪几个词(改读法 / 加同音别名 / 换掉 ASR 不认的写法)。
|
|
869
878
|
warnings.append("ASR 回读字符召回 %.3f 低于 pass 线 %.2f(但高于 fail 线 %.2f):"
|
|
870
879
|
"pass 但记 warning,别当完全没问题" % (recall, recall_pass, recall_warn))
|
|
880
|
+
if lost_counter:
|
|
881
|
+
tips = "、".join("%s×%d" % (ch, n) for ch, n in lost_counter.most_common(12))
|
|
882
|
+
warnings.append("成片里没对上的字(ref 有 / ASR 无,按次数):%s —— 多是专名读法,"
|
|
883
|
+
"改剧本写法或在 film.json 的 qa.asr 放宽" % tips)
|
|
884
|
+
worst = sorted((p for p in per_line if p.get("lost")),
|
|
885
|
+
key=lambda p: float(p.get("recall") or 0.0))[:2]
|
|
886
|
+
for p in worst:
|
|
887
|
+
warnings.append("最差段落 %s(召回 %.2f,%d/%d 字):没对上 %s;ASR 实际读到 %r"
|
|
888
|
+
% (p["id"], float(p["recall"]), p["hit_chars"], p["ref_chars"],
|
|
889
|
+
"、".join(p["lost"]), p["asr_text"][:50]))
|
|
871
890
|
status = "pass"
|
|
872
891
|
else:
|
|
873
892
|
status = "pass"
|
|
@@ -879,6 +898,7 @@ def _judge(scenes, segments, *, recall_pass=QA_RECALL_PASS, recall_warn=QA_RECAL
|
|
|
879
898
|
detail += ";最大偏移 %s %+.2fs" % (worst[0], float(worst[1]))
|
|
880
899
|
return {
|
|
881
900
|
"recall": round(recall, 4), "ref_chars": tot_ref, "hit_chars": tot_hit,
|
|
901
|
+
"lost_top": [{"ch": ch, "n": n} for ch, n in lost_counter.most_common(20)],
|
|
882
902
|
"per_line": per_line, "missing_lines": missing,
|
|
883
903
|
"offsets": offsets, "offset_ref": offset_ref, "offset_warn_s": float(offset_warn),
|
|
884
904
|
"gate": {"id": "asr", "title": "ASR 回读(原稿 vs 成片旁白)",
|
|
@@ -1145,7 +1165,7 @@ def report(film_mp4, tl, script=None, *, model_size="small", lang="zh", out_json
|
|
|
1145
1165
|
rep["audio_s"] = audio_info.get("duration") # 前面探测过,不重复跑 ffprobe
|
|
1146
1166
|
judged = _judge(scenes, rep["asr_segments"], recall_pass=recall_pass,
|
|
1147
1167
|
recall_warn=recall_warn, offset_warn=offset_warn)
|
|
1148
|
-
for key in ("recall", "ref_chars", "hit_chars", "per_line", "missing_lines",
|
|
1168
|
+
for key in ("recall", "ref_chars", "hit_chars", "lost_top", "per_line", "missing_lines",
|
|
1149
1169
|
"offsets", "offset_ref", "gate", "warnings", "errors"):
|
|
1150
1170
|
rep[key] = judged[key]
|
|
1151
1171
|
if asr.get("parse_skipped"):
|
|
@@ -1491,8 +1511,7 @@ def _utf8_stdout() -> None:
|
|
|
1491
1511
|
def main(argv=None) -> int:
|
|
1492
1512
|
ap = argparse.ArgumentParser(
|
|
1493
1513
|
prog="qaasr.py",
|
|
1494
|
-
description="ASR 回读校验:把成片音轨转回文字,与 timeline/剧本做字符级召回与偏移检查"
|
|
1495
|
-
"(v0.8.0)")
|
|
1514
|
+
description="ASR 回读校验:把成片音轨转回文字,与 timeline/剧本做字符级召回与偏移检查")
|
|
1496
1515
|
ap.add_argument("film", nargs="?", help="成片路径(有音轨即可,视频轨不参与)")
|
|
1497
1516
|
ap.add_argument("--timeline", default=None,
|
|
1498
1517
|
help="work/timeline.json(scenes[].id/start/end,可选 voice_start/voice_end/text)")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""qavmaf.py — 全片 VMAF/SSIM
|
|
2
|
+
"""qavmaf.py — 全片 VMAF/SSIM 画质门禁。
|
|
3
3
|
|
|
4
4
|
为什么要有它
|
|
5
5
|
------------
|
|
@@ -83,7 +83,7 @@ report(film_mp4, tl, scenes_mod, script, *, mode="full", out_json=None,
|
|
|
83
83
|
· 宽高比不是 16:9 横屏(本项目只做 16:9 缩放输出);
|
|
84
84
|
· 成片尺寸与 timeline.video 声明的交付尺寸不一致(顶层规格被改动);
|
|
85
85
|
· 读不出成片分辨率。
|
|
86
|
-
-
|
|
86
|
+
- "下采样交付"是合法配置:内部构图恒 1920x1080,编码时 lanczos
|
|
87
87
|
缩到 1280x720 / 960x540。此时本模块**在成片分辨率下比较**——参考流在
|
|
88
88
|
ffmpeg 滤镜图里用 scale=w:h:flags=lanczos 缩到成片分辨率(与 FrameSink
|
|
89
89
|
编码时同口径,不用 PIL resize);返回 dict 的 ref_scale 记录映射关系。
|
|
@@ -108,7 +108,7 @@ CLI
|
|
|
108
108
|
"""
|
|
109
109
|
from __future__ import annotations
|
|
110
110
|
|
|
111
|
-
__version__ = "0.
|
|
111
|
+
__version__ = "1.0.4"
|
|
112
112
|
|
|
113
113
|
import argparse
|
|
114
114
|
import importlib.util
|
|
@@ -451,14 +451,34 @@ def _as_script(script) -> dict:
|
|
|
451
451
|
|
|
452
452
|
# ---------------------------------------------------------------- 参考帧管线(复刻 render.py)
|
|
453
453
|
def _looks_legacy(script) -> bool:
|
|
454
|
-
"""有没有"风格"信息:没有就是
|
|
454
|
+
"""有没有"风格"信息:没有就是 legacy 外观(与 stylekit 的判据一致)。"""
|
|
455
455
|
vis = script.get("visual") if isinstance(script.get("visual"), dict) else {}
|
|
456
456
|
mus = script.get("music") if isinstance(script.get("music"), dict) else {}
|
|
457
457
|
return ("style" not in script) and not vis.get("theme") and not mus.get("style")
|
|
458
458
|
|
|
459
459
|
|
|
460
460
|
def _style_plan(script) -> dict:
|
|
461
|
-
"""
|
|
461
|
+
"""解析剧本风格计划:**design 优先**,其次 script["_style"],最后 stylekit。
|
|
462
|
+
|
|
463
|
+
为什么必须先看 design:"这一版长什么样"由 work/design.json 决定,
|
|
464
|
+
而 qavmaf 是**独立进程**——它拿到的 script 里没有 render 塞进去的 _style。
|
|
465
|
+
直接调 stylekit 会把 design 路径的片子解析成 tech-deep(默认风格),
|
|
466
|
+
参考帧用一套主题、成片用另一套:实测 VMAF 1.50、同源抽查 MAE=0.0384。
|
|
467
|
+
这不是"画质劣化",是**参考帧根本不是同一部片**。
|
|
468
|
+
"""
|
|
469
|
+
# ① design(与 render._plan_from_design 同一口径)
|
|
470
|
+
design = script.get("design") if isinstance(script, dict) else None
|
|
471
|
+
if isinstance(design, dict) and design:
|
|
472
|
+
import designkit as DK
|
|
473
|
+
bad = DK.validate_design(design)
|
|
474
|
+
if bad:
|
|
475
|
+
raise LookupError("design.json 没过校验,参考帧无法与成片同源:%s" % ";".join(bad[:3]))
|
|
476
|
+
return DK.design_to_plan(design)
|
|
477
|
+
# ② 调用方已经解析过(extract_frames 会塞进来)
|
|
478
|
+
cached = script.get("_style") if isinstance(script, dict) else None
|
|
479
|
+
if isinstance(cached, dict) and cached:
|
|
480
|
+
return cached
|
|
481
|
+
# ③ 老路径:stylekit
|
|
462
482
|
try:
|
|
463
483
|
import stylekit as ST
|
|
464
484
|
except Exception as e: # noqa: BLE001
|
|
@@ -495,7 +515,7 @@ ASPECT_TOL = 0.01 # 宽高比容差(与 filmkit.video_plan 一
|
|
|
495
515
|
def _declared_out_size(tl, script):
|
|
496
516
|
"""交付规格:timeline.video(render.py 写出的快照)优先,其次剧本里的 video 块。
|
|
497
517
|
|
|
498
|
-
返回 (out_w, out_h);未声明时返回 None(老 timeline
|
|
518
|
+
返回 (out_w, out_h);未声明时返回 None(老 timeline 没有 video 块时的行为)。
|
|
499
519
|
"""
|
|
500
520
|
for src in ((tl or {}).get("video"), (script or {}).get("video")):
|
|
501
521
|
if not isinstance(src, dict):
|
|
@@ -518,7 +538,7 @@ def _judge_sizes(info, tl, script, comp_w, comp_h):
|
|
|
518
538
|
"scale" 成片是 16:9 下采样交付,参考流在 ffmpeg 滤镜图里 lanczos 缩到成片尺寸
|
|
519
539
|
"fail" 结构性不符:上采样 / 非 16:9 / 读不出尺寸 / 与声明交付尺寸不一致
|
|
520
540
|
|
|
521
|
-
|
|
541
|
+
合法配置是"内部构图恒 1920x1080,编码时 lanczos 缩到 1280x720/960x540"。
|
|
522
542
|
上采样不是合法交付(只会把 1080p 包装成更高分辨率),这里明确挡住。
|
|
523
543
|
"""
|
|
524
544
|
fw, fh = info.get("width"), info.get("height")
|
|
@@ -563,10 +583,17 @@ def _prepare_script(script, tl):
|
|
|
563
583
|
"""
|
|
564
584
|
sc = dict(script) # 浅拷贝:不改调用方的对象
|
|
565
585
|
sc["_total"] = float(_f(tl.get("total"), 0.0))
|
|
566
|
-
|
|
567
|
-
|
|
586
|
+
# design 必须**优先于**缓存的 _style:调用方(render.st_qa)可能已经用一个
|
|
587
|
+
# 错误的 _style 污染过 script —— 它自己解析时没传 work、读不到 design.json,
|
|
588
|
+
# 于是 _style 是 stylekit 的 auto 结果。看到 _style 就直接用,等于沿用那个错误。
|
|
589
|
+
if isinstance(sc.get("design"), dict) and sc["design"]:
|
|
568
590
|
plan = _style_plan(sc)
|
|
569
591
|
sc["_style"] = plan
|
|
592
|
+
else:
|
|
593
|
+
plan = sc.get("_style")
|
|
594
|
+
if not isinstance(plan, dict):
|
|
595
|
+
plan = _style_plan(sc)
|
|
596
|
+
sc["_style"] = plan
|
|
570
597
|
legacy = bool((plan or {}).get("legacy"))
|
|
571
598
|
if legacy:
|
|
572
599
|
return sc, None
|
|
@@ -577,22 +604,48 @@ def _prepare_script(script, tl):
|
|
|
577
604
|
return sc, theme
|
|
578
605
|
|
|
579
606
|
|
|
580
|
-
def
|
|
581
|
-
"""
|
|
607
|
+
def _sub_kw_for(script) -> dict:
|
|
608
|
+
"""从已解析的风格计划里取字幕样式参数(与 render.py:_sub_kw 同口径)。
|
|
609
|
+
|
|
610
|
+
⚠ 为什么参考帧必须传这些参数(实测的**误杀好片**缺陷):
|
|
611
|
+
render.py 的 frames / stills 阶段会传 design 的 visual.subtitle(size/align/
|
|
612
|
+
plate_alpha/radius/max_lines/family/y);参考帧不传就永远是 filmkit 的模块
|
|
613
|
+
默认值(size=46 等)。同一部片(设计 size=52 / radius=14 / plate_alpha=180)
|
|
614
|
+
实测 VMAF 91.33 —— 被阈值 92 判 fail,而两条路径用同一份参数时 ≥96。
|
|
615
|
+
门禁误杀好片比漏查更有害,所以这里必须与主渲染路径吃同一份参数。
|
|
616
|
+
|
|
617
|
+
实现在 filmkit.subtitle_kwargs(**不复制第二份**:两份迟早漂移);
|
|
618
|
+
老版 filmkit 没有这个函数时退回空 dict(= 旧行为,跨版本兼容)。
|
|
619
|
+
"""
|
|
620
|
+
plan = script.get("_style") if isinstance(script, dict) else None
|
|
621
|
+
fn = getattr(_K, "subtitle_kwargs", None)
|
|
622
|
+
if fn is None: # 老 filmkit:还没有字幕样式参数
|
|
623
|
+
return {}
|
|
624
|
+
try:
|
|
625
|
+
kw = fn(plan)
|
|
626
|
+
except Exception: # noqa: BLE001 —— 样式解析失败不该挡住整片门禁
|
|
627
|
+
return {}
|
|
628
|
+
return kw if isinstance(kw, dict) else {}
|
|
629
|
+
|
|
582
630
|
|
|
583
|
-
|
|
631
|
+
def _draw_subtitle_like_render(frame, text, theme, words, kw=None):
|
|
632
|
+
"""烧字幕,与 render.py 同口径(带 words 词边界折行与设计字幕样式)。
|
|
633
|
+
|
|
634
|
+
⚠ 参考帧不传 words 也是**会误杀好片**的缺陷(实测):
|
|
584
635
|
render.py 的 frames / stills 阶段调 draw_subtitle 时总会传 words,
|
|
585
636
|
字幕会按词边界折行("…整整 / 一句旁白");参考帧不传就退回字符折行
|
|
586
637
|
("…整整一 / 句旁白")。38 字字幕只差一个断点、约 2% 像素不同,
|
|
587
638
|
但近静态画面的 VMAF 从 99.95 掉到 8.55 —— 门禁会把同源的好片判 fail。
|
|
588
|
-
|
|
639
|
+
kw 是 design 的字幕样式(见 _sub_kw_for);老版 filmkit 的 draw_subtitle
|
|
640
|
+
没有 words 形参,TypeError 时退回旧调用(保留)。
|
|
589
641
|
"""
|
|
642
|
+
kw = kw or {}
|
|
590
643
|
if not words:
|
|
591
|
-
return _K.draw_subtitle(frame, text, theme=theme)
|
|
644
|
+
return _K.draw_subtitle(frame, text, theme=theme, **kw)
|
|
592
645
|
try:
|
|
593
|
-
return _K.draw_subtitle(frame, text, theme=theme, words=words)
|
|
646
|
+
return _K.draw_subtitle(frame, text, theme=theme, words=words, **kw)
|
|
594
647
|
except TypeError: # 老 filmkit:没有 words 形参(保持旧行为)
|
|
595
|
-
return _K.draw_subtitle(frame, text, theme=theme)
|
|
648
|
+
return _K.draw_subtitle(frame, text, theme=theme, **kw)
|
|
596
649
|
|
|
597
650
|
|
|
598
651
|
def _draw_ref_frame(scenes, scenes_mod, script, theme, fi, fps, total):
|
|
@@ -625,7 +678,9 @@ def _draw_ref_frame(scenes, scenes_mod, script, theme, fi, fps, total):
|
|
|
625
678
|
words = fn(scenes, t, hold) if fn is not None else None
|
|
626
679
|
except Exception: # noqa: BLE001 —— 词表异常不该让参考帧渲不出来
|
|
627
680
|
words = None
|
|
628
|
-
|
|
681
|
+
# 字幕样式也必须与主渲染路径同源(见 _sub_kw_for 的实测说明)
|
|
682
|
+
frame = _draw_subtitle_like_render(frame, text, theme, words,
|
|
683
|
+
kw=_sub_kw_for(script))
|
|
629
684
|
fade = 1.0
|
|
630
685
|
if t < 0.5:
|
|
631
686
|
fade *= _K.ease_out(t / 0.5)
|
|
@@ -650,7 +705,7 @@ def _build_filtergraph(fps, step, threads, has_ssim, vmaf_log, ssim_log, dist_fp
|
|
|
650
705
|
libvmaf/ssim 的 framesync 会按"最近的下界时间戳"凑对,容易错帧。
|
|
651
706
|
- 抽样(step>1)只对成片做 select,参考侧本来只喂抽中的帧,两边帧数天然相等。
|
|
652
707
|
- split=2:**只渲染一遍**参考帧,同时喂 VMAF 与 SSIM 两个滤镜。
|
|
653
|
-
- ref_scale
|
|
708
|
+
- ref_scale:下采样交付时,把参考流用 lanczos 缩到成片分辨率再比——
|
|
654
709
|
与 filmkit.FrameSink 编码时的 -vf scale=out_w:out_h:flags=lanczos 同口径
|
|
655
710
|
(不在 PIL 侧缩,避免两套插值口径混用)。
|
|
656
711
|
"""
|
|
@@ -868,7 +923,7 @@ def report(film_mp4, tl, scenes_mod, script, *, mode="full", out_json=None,
|
|
|
868
923
|
warnings.append("ffmpeg 构建不含 ssim 滤镜,本次只算 VMAF(SSIM 记为 skipped)")
|
|
869
924
|
rep["ffmpeg"] = ff
|
|
870
925
|
|
|
871
|
-
# ---- 3. 成片探测 +
|
|
926
|
+
# ---- 3. 成片探测 + 结构性校验(含下采样交付) ----
|
|
872
927
|
info = _probe_film(film)
|
|
873
928
|
rep["film_info"] = info
|
|
874
929
|
W, H = int(getattr(_K, "W", 1920)), int(getattr(_K, "H", 1080))
|
|
@@ -1229,13 +1284,24 @@ def draw_frame(scene, t_local, dur, t_global, script, frame):
|
|
|
1229
1284
|
fp.write(scenes_src)
|
|
1230
1285
|
script = {"title": "qavmaf selftest", "lines": [
|
|
1231
1286
|
{"id": "l01", "text": "一", "sub": "自检"}]}
|
|
1287
|
+
# 没有内置风格表:合成剧本也必须自带一份**现场生成**的设计,
|
|
1288
|
+
# 否则 _style_plan() 解析不出主题("不写 style 就走老外观"的兼容路径已移除),
|
|
1289
|
+
# 参考帧无法渲染。这里用 designkit 现场采样(seed 固定 = 可复现)、
|
|
1290
|
+
# design_to_plan 与 render 同一口径,**不引入任何写死主题/模板**。
|
|
1291
|
+
try:
|
|
1292
|
+
import designkit as _DK
|
|
1293
|
+
_design = _DK.sample_designs({"topic": "qavmaf selftest"}, n=1, seed=1)[0]
|
|
1294
|
+
script["_style"] = _DK.design_to_plan(_design)
|
|
1295
|
+
except Exception as e: # noqa: BLE001 —— 缺 designkit 也要给出可读的 FAIL
|
|
1296
|
+
print("[FAIL] 自检需要 designkit.py 现场生成设计(无内置风格):%s" % e)
|
|
1297
|
+
return 1
|
|
1232
1298
|
scenes = [dict(script["lines"][0], start=0.0, end=n / float(fps),
|
|
1233
1299
|
dur=n / float(fps), voice_start=0.0, voice_end=n / float(fps))]
|
|
1234
1300
|
tl = {"total": n / float(fps), "fps": fps, "frames": n, "scenes": scenes,
|
|
1235
1301
|
"video": {"out_w": W, "out_h": H, "scale": False}}
|
|
1236
|
-
#
|
|
1302
|
+
# 下采样交付:内部构图仍 1920x1080,交付 1280x720(编码时 lanczos 缩)
|
|
1237
1303
|
tl720 = dict(tl, video={"out_w": 1280, "out_h": 720, "scale": True})
|
|
1238
|
-
# 老 timeline
|
|
1304
|
+
# 老 timeline(没有 video 块):只按"构图同尺寸"校验
|
|
1239
1305
|
tl_legacy = {k: v for k, v in tl.items() if k != "video"}
|
|
1240
1306
|
# 非法:声明 2560x1440 输出(内部构图只有 1080p,等于上采样)
|
|
1241
1307
|
tl_up = dict(tl, video={"out_w": 2560, "out_h": 1440, "scale": True})
|
|
@@ -1310,7 +1376,7 @@ def draw_frame(scene, t_local, dur, t_global, script, frame):
|
|
|
1310
1376
|
return ok and any_w, "frames_compared=%s" % rep["frames_compared"]
|
|
1311
1377
|
|
|
1312
1378
|
def c_downscale_pass():
|
|
1313
|
-
#
|
|
1379
|
+
# 合法配置:1920x1080 内部构图 -> 1280x720 交付,必须 pass;
|
|
1314
1380
|
# 且与 1080p 同规格成片的 VMAF 差 < 3 分(阈值可比)
|
|
1315
1381
|
rep = report(film_720, tl720, mod, script, mode="full", log=None)
|
|
1316
1382
|
v1080 = report(film_ok, mode="full", log=None, **base)["vmaf"]["score"]
|
|
@@ -1388,7 +1454,7 @@ def draw_frame(scene, t_local, dur, t_global, script, frame):
|
|
|
1388
1454
|
def main(argv=None) -> int:
|
|
1389
1455
|
ap = argparse.ArgumentParser(
|
|
1390
1456
|
prog="qavmaf.py",
|
|
1391
|
-
description="全片 VMAF/SSIM
|
|
1457
|
+
description="全片 VMAF/SSIM 画质门禁:参考帧由场景函数实时渲染")
|
|
1392
1458
|
ap.add_argument("--film", default=None, help="成片路径(mp4)")
|
|
1393
1459
|
ap.add_argument("--timeline", default=None, help="work/timeline.json")
|
|
1394
1460
|
ap.add_argument("--scenes", default=None, help="scenes.py(含 draw_frame)")
|