@dsh-gis/dsh-video-studio-skill 0.7.2 → 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 +51 -8
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +4 -1
- package/package.json +5 -3
- package/schemas/film.schema.json +162 -2
- package/skills/video-studio/SKILL.md +211 -11
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/web/engine.js +63 -2
- package/skills/video-studio/assets/web/film.js +224 -101
- package/skills/video-studio/assets/web/index.html +4 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +4 -3
- package/skills/video-studio/references/audio.md +2 -2
- package/skills/video-studio/references/change-impact.md +1 -1
- 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 +14 -0
- package/skills/video-studio/scripts/filmcheck.py +121 -0
- package/skills/video-studio/scripts/filmkit.py +521 -38
- package/skills/video-studio/scripts/gates.py +88 -4
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +49 -8
- 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 +719 -32
- package/skills/video-studio/scripts/scorekit.py +16 -30
- package/skills/video-studio/scripts/selfcheck.py +274 -0
- package/skills/video-studio/scripts/sfxkit.py +130 -14
- package/skills/video-studio/scripts/stagewatch.py +19 -6
- package/skills/video-studio/scripts/stylekit.py +43 -12
- package/skills/video-studio/scripts/voicekit.py +15 -7
- package/skills/video-studio/scripts/webengine.py +1119 -72
- package/tools/ci_check.py +171 -31
- package/tools/estimate.py +130 -19
- package/tools/golden_frames.py +664 -0
|
@@ -0,0 +1,1442 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""qavmaf.py — 全片 VMAF/SSIM 画质门禁(v0.8.0)。
|
|
3
|
+
|
|
4
|
+
为什么要有它
|
|
5
|
+
------------
|
|
6
|
+
qa_frames.py 只看"帧级异常"(白闪/空窗/静止/同步),看不出"整片画质掉了多少":
|
|
7
|
+
CRF 调高、preset 降档、二次编码、缩放插值,都会让画面"糊一点",但每帧都正常。
|
|
8
|
+
VMAF(Netflix 感知质量分,0–100)就是量化这件事的标准尺子。
|
|
9
|
+
|
|
10
|
+
历史坑(必须避开)
|
|
11
|
+
------------------
|
|
12
|
+
**参考帧必须由场景函数实时重渲染**(scenes.draw_frame),绝不能拿成片自己抽的帧
|
|
13
|
+
当参考——那是"拿成片和成片比",VMAF 恒等于 100,门禁形同虚设。
|
|
14
|
+
所以本模块的参考帧 = render.py 的 frames 阶段同一套绘制管线:
|
|
15
|
+
draw_frame -> draw_subtitle -> 片头/片尾淡入淡出(wash/black)
|
|
16
|
+
逐帧生成 rgb24,经 stdin 管道喂给 ffmpeg 的 rawvideo 输入,与成片解码流做
|
|
17
|
+
libvmaf / ssim 比较。
|
|
18
|
+
|
|
19
|
+
性能与流式
|
|
20
|
+
----------
|
|
21
|
+
1080p 每帧 1920x1080x3 = 6.22 MB。本模块**只渲一遍帧、边渲边喂**,内存恒定
|
|
22
|
+
(不缓存整片帧序列);一次 ffmpeg 调用里用 filter_complex + split 同时算 VMAF
|
|
23
|
+
与 SSIM,不需要渲染第二遍。
|
|
24
|
+
实测(本机 12 核 / 24 线程,无 CUDA,仓库模板片 30.6 s / 918 帧 / 1080p,
|
|
25
|
+
2026-11-10 实测):
|
|
26
|
+
- 全片逐帧(参考帧渲染 + VMAF+SSIM 联合比较)12.0 s,约 0.40x 实时;
|
|
27
|
+
参考帧本身约 10 ms/帧(模板场景已缓存背景:绘制 6.0 ms + tobytes 4.2 ms),
|
|
28
|
+
与 libvmaf 消费流水重叠;瓶颈在参考帧渲染侧(消费端约 76 帧/秒,
|
|
29
|
+
渲染端约 99 帧/秒,实际总吞吐约 77 帧/秒 ≈ 两者中较慢的一段加上少量同步开销)。
|
|
30
|
+
- 按此量级,12 分钟片约 5 分钟(真实创意场景 draw_frame 更重,会线性变慢)。
|
|
31
|
+
- sample 模式(每 5 帧取 1 帧)2.95 s 跑完 184 帧,VMAF 与全片差 0.34 分。
|
|
32
|
+
- libvmaf threads=0 只有约 20 帧/秒;默认 min(8, CPU) 时约 124–160 帧/秒。
|
|
33
|
+
分数对线程数完全不敏感:threads=0/1/4/8/16 与跨进程复跑,240 帧逐帧
|
|
34
|
+
VMAF 分数 sha1 全部相同——多线程不影响确定性。
|
|
35
|
+
- 下采样交付(1280x720 / 960x540,参考流 lanczos 缩到成片尺寸)与 1080p
|
|
36
|
+
同片比:VMAF 差 -0.22 / -0.03 分。
|
|
37
|
+
|
|
38
|
+
契约
|
|
39
|
+
----
|
|
40
|
+
report(film_mp4, tl, scenes_mod, script, *, mode="full", out_json=None,
|
|
41
|
+
vmaf_pass=92.0, vmaf_warn=96.0, ssim_pass=0.95, ssim_warn=0.98,
|
|
42
|
+
limit_seconds=None, sample_step=None, threads=DEFAULT_THREADS,
|
|
43
|
+
log=print) -> dict
|
|
44
|
+
|
|
45
|
+
film_mp4 : 成片路径(mp4;只比较视频流)
|
|
46
|
+
tl : timeline.json 的路径或已载入的 dict(需要 fps/total/frames/scenes)
|
|
47
|
+
scenes_mod : 场景模块对象(含 draw_frame)或 scenes.py 路径
|
|
48
|
+
script : film.json 的路径或已载入的 dict(需要与 frames 阶段同一份;
|
|
49
|
+
含 style 解析结果时直接用,否则本模块按 stylekit 重新解析)
|
|
50
|
+
mode : "full" 全片逐帧 / "sample" 抽样(默认每 5 帧取 1 帧)
|
|
51
|
+
vmaf_pass : VMAF 均值低于此值 -> gate.status="fail"(默认 92.0)
|
|
52
|
+
vmaf_warn : VMAF 均值低于此值但 >= vmaf_pass -> pass + warning(默认 96.0)
|
|
53
|
+
ssim_pass/warn : SSIM 阈值,**诊断项**(见"三档语义"第 3 条)
|
|
54
|
+
limit_seconds : 只比较前 N 秒(自测/冒烟用;会记 warning 说明非全片)
|
|
55
|
+
sample_step : 抽样步长(默认 SAMPLE_STEP=5;mode="full" 时给了它也按抽样算)
|
|
56
|
+
threads : libvmaf 线程数(默认 min(8, CPU);实测多线程不影响分数)
|
|
57
|
+
|
|
58
|
+
返回 dict:
|
|
59
|
+
judged bool 是否真的比较了帧(False = 没查成,绝对不能当通过)
|
|
60
|
+
mode str "full" / "sample"
|
|
61
|
+
vmaf/ssim dict {score, pass, warn, status, detail, min, ...}
|
|
62
|
+
status ∈ pass / warn / fail / skipped
|
|
63
|
+
frames_compared int 实际参与比较的帧数
|
|
64
|
+
elapsed_s float 总耗时(含参考帧渲染)
|
|
65
|
+
warnings list 给人看的中文说明(含"本次没查/只查了一部分")
|
|
66
|
+
errors list 导致 fail 的硬错误
|
|
67
|
+
gate dict {status: "pass"|"fail"|"skipped", detail}
|
|
68
|
+
ref_scale dict|None 参考流缩放映射({"from":[1920,1080],"to":[1280,720]};
|
|
69
|
+
成片与内部构图同尺寸时为 None)
|
|
70
|
+
|
|
71
|
+
三档语义(与 gates.py 的 OK/FAIL/SKIP 同义)
|
|
72
|
+
-------------------------------------------
|
|
73
|
+
1. **fail**:VMAF 均值 < vmaf_pass(默认 92)。这是唯一让质检阻断的情况。
|
|
74
|
+
2. **pass**:VMAF 均值 >= vmaf_pass;其中 [vmaf_pass, vmaf_warn) 区间记 warning
|
|
75
|
+
("通过了,但低于建议阈值,建议复核")。
|
|
76
|
+
3. **skipped**:本次**没有查**,必须写明原因,**绝不冒充 pass**。触发条件:
|
|
77
|
+
成片不存在 / 缺 timeline / 缺 scenes(或 draw_frame 抛错)/ 缺 filmkit /
|
|
78
|
+
找不到 ffmpeg / ffmpeg 构建没有 libvmaf / 成片解码失败 / 比较 0 帧 /
|
|
79
|
+
参考帧渲染中断(宁可 skipped 也不给"只查了一半"的 pass)。
|
|
80
|
+
- 分辨率结构性不符 → **fail**(不是"没法查"):
|
|
81
|
+
· 成片分辨率 > 内部构图分辨率(1920x1080):**上采样不是合法交付**,
|
|
82
|
+
只会把 1080p 包装成"更高分辨率";
|
|
83
|
+
· 宽高比不是 16:9 横屏(本项目只做 16:9 缩放输出);
|
|
84
|
+
· 成片尺寸与 timeline.video 声明的交付尺寸不一致(顶层规格被改动);
|
|
85
|
+
· 读不出成片分辨率。
|
|
86
|
+
- v0.8 的"下采样交付"是合法配置:内部构图恒 1920x1080,编码时 lanczos
|
|
87
|
+
缩到 1280x720 / 960x540。此时本模块**在成片分辨率下比较**——参考流在
|
|
88
|
+
ffmpeg 滤镜图里用 scale=w:h:flags=lanczos 缩到成片分辨率(与 FrameSink
|
|
89
|
+
编码时同口径,不用 PIL resize);返回 dict 的 ref_scale 记录映射关系。
|
|
90
|
+
VMAF 阈值按 1080p 标定,下采样交付沿用同一套(实测 720p 与 1080p
|
|
91
|
+
的 VMAF 差不到 3 分)。
|
|
92
|
+
- SSIM 是诊断项不单独阻断(VMAF 才是感知门禁,且文本画面的 SSIM 对字幕
|
|
93
|
+
边缘过于敏感);SSIM 低于阈值只记 warning,但会写得足够醒目。
|
|
94
|
+
|
|
95
|
+
CLI
|
|
96
|
+
---
|
|
97
|
+
python qavmaf.py --film out/film.mp4 --timeline work/timeline.json \
|
|
98
|
+
--scenes scenes.py --script film.json [--mode full|sample] \
|
|
99
|
+
[--limit-seconds 30] [--out work/qa/vmaf.json] [--json]
|
|
100
|
+
|
|
101
|
+
退出码:0 = pass;1 = fail;2 = skipped(CI 必须把 2 也当"没通过"处理)
|
|
102
|
+
--json 只把报告 JSON 打到 stdout(人读摘要走 stderr,便于管道消费)
|
|
103
|
+
--selftest 跑内置自检(合成片,验证三档语义不许"没查写成通过")
|
|
104
|
+
|
|
105
|
+
依赖:stdlib + numpy(分位数)+ Pillow(参考帧)+ 同目录 filmkit.py(W/H、字幕、
|
|
106
|
+
ffmpeg/ffprobe 探测、探测函数)。ffmpeg 通过 FILMKIT_FFMPEG 覆盖,
|
|
107
|
+
找不到可执行文件一律 fail-closed(skipped + 明确原因,不裸调用 "ffmpeg")。
|
|
108
|
+
"""
|
|
109
|
+
from __future__ import annotations
|
|
110
|
+
|
|
111
|
+
__version__ = "0.8.0"
|
|
112
|
+
|
|
113
|
+
import argparse
|
|
114
|
+
import importlib.util
|
|
115
|
+
import json
|
|
116
|
+
import math
|
|
117
|
+
import os
|
|
118
|
+
import re
|
|
119
|
+
import shutil
|
|
120
|
+
import subprocess
|
|
121
|
+
import sys
|
|
122
|
+
import tempfile
|
|
123
|
+
import time
|
|
124
|
+
|
|
125
|
+
import numpy as np
|
|
126
|
+
from PIL import Image, ImageFilter
|
|
127
|
+
|
|
128
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
129
|
+
if HERE not in sys.path:
|
|
130
|
+
sys.path.insert(0, HERE)
|
|
131
|
+
|
|
132
|
+
try: # 同目录 filmkit 提供 W/H、字幕、ffmpeg 探测
|
|
133
|
+
import filmkit as _K
|
|
134
|
+
except Exception: # noqa: BLE001 —— 独立拷贝本文件时也不该在 import 阶段炸
|
|
135
|
+
_K = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------- 可调参数
|
|
139
|
+
# VMAF 阈值:本机实测 crf17 模板片 ~98+,crf30 明显掉档(见 .team 实测报告)。
|
|
140
|
+
# 92 是"明显劣化"的失败线,96 是"建议复核"线;两者都可用 CLI 覆盖。
|
|
141
|
+
VMAF_PASS = 92.0
|
|
142
|
+
VMAF_WARN = 96.0
|
|
143
|
+
# SSIM 诊断阈值(只记 warning,不单独阻断)
|
|
144
|
+
SSIM_PASS = 0.95
|
|
145
|
+
SSIM_WARN = 0.98
|
|
146
|
+
# 抽样模式默认步长:30 fps 下每 5 帧取 1 帧 = 6 fps,约 1/5 耗时
|
|
147
|
+
SAMPLE_STEP = 5
|
|
148
|
+
# 参考帧与成片同一时刻的像素差超过这个值就记 warning(0–1 归一化 MAE)。
|
|
149
|
+
# 正常同源时只有编码噪声(实测 << 0.01);接近 0.05 说明渲染管线已漂移
|
|
150
|
+
# (例如 render.py 改了字幕/淡入淡出,而本模块还在复刻旧逻辑)。
|
|
151
|
+
SPOT_WARN_MAE = 0.02
|
|
152
|
+
# 抽帧对齐误差上限:成片帧数与时间轴帧数差超过这个比例,说明时间轴与成片不是同一次渲染
|
|
153
|
+
FRAME_COUNT_TOL = 0.02
|
|
154
|
+
# 单帧采样分辨率(spot check 用)
|
|
155
|
+
SPOT_W = 160
|
|
156
|
+
SPOT_H = 90
|
|
157
|
+
|
|
158
|
+
_FFMPEG_CACHE = ""
|
|
159
|
+
_FFPROBE_CACHE = ""
|
|
160
|
+
_CAP_CACHE = {} # {ffmpeg 路径: {"libvmaf": bool, "ssim": bool}}
|
|
161
|
+
# libvmaf 线程数默认值:实测 threads=0 时 libvmaf 只用 1–2 个线程(约 20 帧/秒),
|
|
162
|
+
# 8 线程约 124 帧/秒、16 线程约 142–164 帧/秒,而**分数逐位相同**(见 det 实测),
|
|
163
|
+
# 所以默认显式给线程数,别把 20 帧/秒的默认值留在这条门禁上。
|
|
164
|
+
DEFAULT_THREADS = min(8, os.cpu_count() or 4)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------- 小工具
|
|
168
|
+
def _finite(v) -> bool:
|
|
169
|
+
try:
|
|
170
|
+
return math.isfinite(float(v))
|
|
171
|
+
except (TypeError, ValueError):
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _f(v, default=0.0) -> float:
|
|
176
|
+
try:
|
|
177
|
+
x = float(v)
|
|
178
|
+
return x if math.isfinite(x) else float(default)
|
|
179
|
+
except (TypeError, ValueError):
|
|
180
|
+
return float(default)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _tail(text, n=400) -> str:
|
|
184
|
+
s = str(text or "").strip().replace("\r", "")
|
|
185
|
+
return s[-n:] if len(s) > n else s
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _setup_stdio():
|
|
189
|
+
"""输出被重定向时切 UTF-8(Windows 控制台 GBK 会把中文日志打成乱码)。"""
|
|
190
|
+
for stream in (sys.stdout, sys.stderr):
|
|
191
|
+
try:
|
|
192
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
193
|
+
except Exception: # noqa: BLE001
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _write_json(data, path):
|
|
198
|
+
path = str(path)
|
|
199
|
+
d = os.path.dirname(os.path.abspath(path))
|
|
200
|
+
if d:
|
|
201
|
+
os.makedirs(d, exist_ok=True)
|
|
202
|
+
txt = json.dumps(data, ensure_ascii=False, indent=1, allow_nan=False)
|
|
203
|
+
with open(path, "w", encoding="utf-8") as fp:
|
|
204
|
+
fp.write(txt + "\n")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------- ffmpeg / ffprobe
|
|
208
|
+
def _probe_exe(cand):
|
|
209
|
+
"""把候选解析成真实可执行文件路径;解析不了返回 ""(不做"裸命令兜底")。"""
|
|
210
|
+
cand = str(cand or "").strip().strip('"')
|
|
211
|
+
if not cand:
|
|
212
|
+
return ""
|
|
213
|
+
if os.path.isfile(cand):
|
|
214
|
+
return os.path.abspath(cand)
|
|
215
|
+
looks_pathy = os.path.isabs(cand) or os.sep in cand or bool(os.altsep and os.altsep in cand)
|
|
216
|
+
if looks_pathy:
|
|
217
|
+
return ""
|
|
218
|
+
hit = shutil.which(cand)
|
|
219
|
+
return os.path.abspath(hit) if hit else ""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _ffmpeg() -> str:
|
|
223
|
+
"""解析 ffmpeg:FILMKIT_FFMPEG > filmkit.FFMPEG > PATH。找不到就抛 ValueError。
|
|
224
|
+
|
|
225
|
+
与 qa_frames 的宽松版不同:这里**不**回退裸命令 "ffmpeg"——门禁必须
|
|
226
|
+
fail-closed,让 report() 把"缺能力"写成 skipped,而不是等 subprocess
|
|
227
|
+
在远处抛 FileNotFoundError 再被人当"通过"。
|
|
228
|
+
"""
|
|
229
|
+
global _FFMPEG_CACHE
|
|
230
|
+
if _FFMPEG_CACHE:
|
|
231
|
+
return _FFMPEG_CACHE
|
|
232
|
+
env = (os.environ.get("FILMKIT_FFMPEG") or "").strip()
|
|
233
|
+
if env:
|
|
234
|
+
exe = _probe_exe(env)
|
|
235
|
+
if exe:
|
|
236
|
+
_FFMPEG_CACHE = exe
|
|
237
|
+
return exe
|
|
238
|
+
raise ValueError("FILMKIT_FFMPEG=%r 指向的文件不存在或不可执行" % env)
|
|
239
|
+
if _K is not None:
|
|
240
|
+
exe = _probe_exe(getattr(_K, "FFMPEG", ""))
|
|
241
|
+
if exe:
|
|
242
|
+
_FFMPEG_CACHE = exe
|
|
243
|
+
return exe
|
|
244
|
+
exe = _probe_exe(shutil.which("ffmpeg") or "")
|
|
245
|
+
if not exe:
|
|
246
|
+
raise ValueError("找不到 ffmpeg 可执行文件:请设 FILMKIT_FFMPEG 或把 ffmpeg 放进 PATH")
|
|
247
|
+
_FFMPEG_CACHE = exe
|
|
248
|
+
return exe
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _ffprobe() -> str:
|
|
252
|
+
"""ffprobe 解析:优先 filmkit.ffprobe_path(),否则找 ffmpeg 同目录。"""
|
|
253
|
+
global _FFPROBE_CACHE
|
|
254
|
+
if _FFPROBE_CACHE:
|
|
255
|
+
return _FFPROBE_CACHE
|
|
256
|
+
if _K is not None:
|
|
257
|
+
try:
|
|
258
|
+
p = _probe_exe(_K.ffprobe_path())
|
|
259
|
+
if p:
|
|
260
|
+
_FFPROBE_CACHE = p
|
|
261
|
+
return p
|
|
262
|
+
except Exception: # noqa: BLE001
|
|
263
|
+
pass
|
|
264
|
+
ff = _ffmpeg()
|
|
265
|
+
d = os.path.dirname(ff)
|
|
266
|
+
names = ("ffprobe.exe", "ffprobe") if os.name == "nt" else ("ffprobe", "ffprobe.exe")
|
|
267
|
+
for name in names:
|
|
268
|
+
p = os.path.join(d, name)
|
|
269
|
+
if os.path.isfile(p):
|
|
270
|
+
_FFPROBE_CACHE = p
|
|
271
|
+
return p
|
|
272
|
+
hit = _probe_exe(shutil.which("ffprobe") or "")
|
|
273
|
+
if hit:
|
|
274
|
+
_FFPROBE_CACHE = hit
|
|
275
|
+
return hit
|
|
276
|
+
_FFPROBE_CACHE = "" # 允许为空:_probe_film 会退回 ffmpeg -i 解析
|
|
277
|
+
return ""
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _run(cmd, **kw):
|
|
281
|
+
"""跑一个短命令,返回 CompletedProcess(不抛异常)。"""
|
|
282
|
+
return subprocess.run(cmd, capture_output=True, **kw)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _has_filter(listing: str, name: str) -> bool:
|
|
286
|
+
"""在 ffmpeg -filters 输出里找滤镜名(形如 ".. libvmaf VV->V ...")。"""
|
|
287
|
+
for ln in str(listing or "").splitlines():
|
|
288
|
+
parts = ln.split()
|
|
289
|
+
for i, tok in enumerate(parts):
|
|
290
|
+
if tok != name or i == 0:
|
|
291
|
+
continue
|
|
292
|
+
flags = parts[i - 1]
|
|
293
|
+
if not flags or set(flags) - set("TSC."):
|
|
294
|
+
continue
|
|
295
|
+
if i + 1 < len(parts) and "->" in parts[i + 1]:
|
|
296
|
+
return True
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _capabilities(ff: str) -> dict:
|
|
301
|
+
"""探测 ffmpeg 是否带 libvmaf / ssim(只探一次,按可执行路径缓存)。"""
|
|
302
|
+
key = os.path.normcase(os.path.abspath(ff))
|
|
303
|
+
hit = _CAP_CACHE.get(key)
|
|
304
|
+
if hit is not None:
|
|
305
|
+
return hit
|
|
306
|
+
p = _run([ff, "-hide_banner", "-filters"])
|
|
307
|
+
txt = (p.stdout + p.stderr).decode("utf-8", "replace")
|
|
308
|
+
cap = {"libvmaf": _has_filter(txt, "libvmaf"), "ssim": _has_filter(txt, "ssim")}
|
|
309
|
+
_CAP_CACHE[key] = cap
|
|
310
|
+
return cap
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _parse_rate(txt):
|
|
314
|
+
"""把 ffprobe 的 "30000/1001" / "30" 解析成帧率;解析不了返回 None。"""
|
|
315
|
+
txt = str(txt or "").strip()
|
|
316
|
+
if not txt:
|
|
317
|
+
return None
|
|
318
|
+
try:
|
|
319
|
+
if "/" in txt:
|
|
320
|
+
num, _, den = txt.partition("/")
|
|
321
|
+
num, den = float(num), float(den or 0)
|
|
322
|
+
return num / den if num > 0 and den > 0 else None
|
|
323
|
+
v = float(txt)
|
|
324
|
+
return v if v > 0 else None
|
|
325
|
+
except (TypeError, ValueError):
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _probe_film(film) -> dict:
|
|
330
|
+
"""探测成片视频流:宽高 / 帧率 / 时长 / 帧数 / 编码。失败时字段为 None/0。"""
|
|
331
|
+
info = {"width": None, "height": None, "fps": None, "duration": 0.0,
|
|
332
|
+
"nb_frames": None, "codec": "", "source": "none"}
|
|
333
|
+
if _K is not None:
|
|
334
|
+
try:
|
|
335
|
+
raw = _K.probe(film) or {}
|
|
336
|
+
except Exception: # noqa: BLE001
|
|
337
|
+
raw = {}
|
|
338
|
+
for st in (raw.get("streams") or []):
|
|
339
|
+
if str(st.get("codec_type") or "") != "video":
|
|
340
|
+
continue
|
|
341
|
+
try:
|
|
342
|
+
info["width"] = int(st.get("width") or 0) or None
|
|
343
|
+
info["height"] = int(st.get("height") or 0) or None
|
|
344
|
+
except (TypeError, ValueError):
|
|
345
|
+
pass
|
|
346
|
+
info["fps"] = _parse_rate(st.get("r_frame_rate")) or _parse_rate(st.get("avg_frame_rate"))
|
|
347
|
+
info["codec"] = str(st.get("codec_name") or "")
|
|
348
|
+
try:
|
|
349
|
+
info["nb_frames"] = int(st.get("nb_frames") or 0) or None
|
|
350
|
+
except (TypeError, ValueError):
|
|
351
|
+
info["nb_frames"] = None
|
|
352
|
+
try:
|
|
353
|
+
info["duration"] = float((raw.get("format") or {}).get("duration") or 0) or \
|
|
354
|
+
float(st.get("duration") or 0)
|
|
355
|
+
except (TypeError, ValueError):
|
|
356
|
+
info["duration"] = 0.0
|
|
357
|
+
info["source"] = "filmkit.probe"
|
|
358
|
+
break
|
|
359
|
+
if info["width"] is None:
|
|
360
|
+
pp = _ffprobe()
|
|
361
|
+
if pp:
|
|
362
|
+
p = _run([pp, "-v", "error", "-show_entries",
|
|
363
|
+
"stream=codec_type,codec_name,width,height,r_frame_rate,avg_frame_rate,"
|
|
364
|
+
"nb_frames,duration:format=duration",
|
|
365
|
+
"-of", "json", str(film)])
|
|
366
|
+
if p.returncode == 0 and p.stdout:
|
|
367
|
+
try:
|
|
368
|
+
data = json.loads(p.stdout.decode("utf-8", "replace"))
|
|
369
|
+
except Exception: # noqa: BLE001
|
|
370
|
+
data = {}
|
|
371
|
+
for st in (data.get("streams") or []):
|
|
372
|
+
if str(st.get("codec_type") or "") != "video":
|
|
373
|
+
continue
|
|
374
|
+
info["width"] = int(st.get("width") or 0) or None
|
|
375
|
+
info["height"] = int(st.get("height") or 0) or None
|
|
376
|
+
info["fps"] = _parse_rate(st.get("r_frame_rate")) or \
|
|
377
|
+
_parse_rate(st.get("avg_frame_rate"))
|
|
378
|
+
info["codec"] = str(st.get("codec_name") or "")
|
|
379
|
+
try:
|
|
380
|
+
info["nb_frames"] = int(st.get("nb_frames") or 0) or None
|
|
381
|
+
except (TypeError, ValueError):
|
|
382
|
+
info["nb_frames"] = None
|
|
383
|
+
try:
|
|
384
|
+
info["duration"] = float((data.get("format") or {}).get("duration") or 0)
|
|
385
|
+
except (TypeError, ValueError):
|
|
386
|
+
info["duration"] = 0.0
|
|
387
|
+
info["source"] = "ffprobe"
|
|
388
|
+
break
|
|
389
|
+
return info
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ---------------------------------------------------------------- 装载输入
|
|
393
|
+
def load_module(path, name="qavmaf_scenes"):
|
|
394
|
+
"""按独立模块名加载 scenes.py(避免跨片污染,与 render.py.load_module 同口径)。"""
|
|
395
|
+
path = os.path.abspath(str(path))
|
|
396
|
+
if not os.path.exists(path):
|
|
397
|
+
raise ValueError("scenes.py 不存在:%s" % path)
|
|
398
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
399
|
+
if spec is None or spec.loader is None:
|
|
400
|
+
raise ValueError("scenes.py 无法作为模块加载:%s" % path)
|
|
401
|
+
mod = importlib.util.module_from_spec(spec)
|
|
402
|
+
sys.modules[name] = mod
|
|
403
|
+
try:
|
|
404
|
+
spec.loader.exec_module(mod)
|
|
405
|
+
finally:
|
|
406
|
+
# 失败也把半成品从 sys.modules 摘掉,避免下次加载命中坏模块
|
|
407
|
+
if not hasattr(mod, "draw_frame"):
|
|
408
|
+
sys.modules.pop(name, None)
|
|
409
|
+
return mod
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _as_timeline(tl) -> dict:
|
|
413
|
+
if isinstance(tl, dict):
|
|
414
|
+
return tl
|
|
415
|
+
path = os.path.abspath(str(tl))
|
|
416
|
+
if not os.path.exists(path):
|
|
417
|
+
raise ValueError("timeline 不存在:%s" % path)
|
|
418
|
+
try:
|
|
419
|
+
with open(path, encoding="utf-8") as fp:
|
|
420
|
+
data = json.load(fp)
|
|
421
|
+
except json.JSONDecodeError as e:
|
|
422
|
+
raise ValueError("timeline 不是合法 JSON:%s(%s)" % (path, e)) from e
|
|
423
|
+
if not isinstance(data, dict):
|
|
424
|
+
raise ValueError("timeline 顶层必须是对象:%s" % path)
|
|
425
|
+
return data
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _as_scenes(scenes_mod):
|
|
429
|
+
if scenes_mod is None:
|
|
430
|
+
raise ValueError("没有场景模块(scenes.py):参考帧必须由场景函数实时渲染")
|
|
431
|
+
if hasattr(scenes_mod, "draw_frame"):
|
|
432
|
+
return scenes_mod
|
|
433
|
+
return load_module(scenes_mod)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _as_script(script) -> dict:
|
|
437
|
+
if isinstance(script, dict):
|
|
438
|
+
return dict(script)
|
|
439
|
+
path = os.path.abspath(str(script))
|
|
440
|
+
if not os.path.exists(path):
|
|
441
|
+
raise ValueError("剧本不存在:%s" % path)
|
|
442
|
+
try:
|
|
443
|
+
with open(path, encoding="utf-8") as fp:
|
|
444
|
+
data = json.load(fp)
|
|
445
|
+
except json.JSONDecodeError as e:
|
|
446
|
+
raise ValueError("剧本不是合法 JSON:%s(%s)" % (path, e)) from e
|
|
447
|
+
if not isinstance(data, dict):
|
|
448
|
+
raise ValueError("剧本顶层必须是对象:%s" % path)
|
|
449
|
+
return data
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ---------------------------------------------------------------- 参考帧管线(复刻 render.py)
|
|
453
|
+
def _looks_legacy(script) -> bool:
|
|
454
|
+
"""有没有"风格"信息:没有就是 v0.4 legacy 外观(与 stylekit 的判据一致)。"""
|
|
455
|
+
vis = script.get("visual") if isinstance(script.get("visual"), dict) else {}
|
|
456
|
+
mus = script.get("music") if isinstance(script.get("music"), dict) else {}
|
|
457
|
+
return ("style" not in script) and not vis.get("theme") and not mus.get("style")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _style_plan(script) -> dict:
|
|
461
|
+
"""解析剧本风格计划:优先复用 script["_style"],否则调 stylekit(纯函数、确定性)。"""
|
|
462
|
+
try:
|
|
463
|
+
import stylekit as ST
|
|
464
|
+
except Exception as e: # noqa: BLE001
|
|
465
|
+
if _looks_legacy(script):
|
|
466
|
+
return {"legacy": True, "style": "legacy", "theme_id": None, "theme": None}
|
|
467
|
+
raise LookupError("缺 stylekit.py,无法解析剧本风格(%s);"
|
|
468
|
+
"参考帧外观无法与成片同源" % e) from e
|
|
469
|
+
return ST.resolve_script_style(script)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _theme_of(plan):
|
|
473
|
+
"""复刻 render.py:_theme_for —— 非 legacy 返回 stylekit 的**基础主题**。
|
|
474
|
+
|
|
475
|
+
注意:render.py 的 _theme_for 用的是 THEMES[plan["theme_id"]](基础主题,
|
|
476
|
+
不含 film.json 的 visual.theme_overrides),字幕颜色/字体族都从这里取。
|
|
477
|
+
本函数必须与它逐个字段一致,否则参考帧的字幕与成片不同源、VMAF 会被误拉低。
|
|
478
|
+
"""
|
|
479
|
+
tid = plan.get("theme_id")
|
|
480
|
+
try:
|
|
481
|
+
import stylekit as ST
|
|
482
|
+
themes = getattr(ST, "THEMES", {}) or {}
|
|
483
|
+
if tid in themes:
|
|
484
|
+
return themes[tid]
|
|
485
|
+
except Exception: # noqa: BLE001
|
|
486
|
+
pass
|
|
487
|
+
th = plan.get("theme")
|
|
488
|
+
return th if isinstance(th, dict) else None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
COMPARED_ASPECT = 16.0 / 9.0 # 只支持 16:9 横屏(与 filmkit.VIDEO_ASPECT 同口径)
|
|
492
|
+
ASPECT_TOL = 0.01 # 宽高比容差(与 filmkit.video_plan 一致)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _declared_out_size(tl, script):
|
|
496
|
+
"""交付规格:timeline.video(render.py 写出的快照)优先,其次剧本里的 video 块。
|
|
497
|
+
|
|
498
|
+
返回 (out_w, out_h);未声明时返回 None(老 timeline 升级前生成,行为同 v0.7)。
|
|
499
|
+
"""
|
|
500
|
+
for src in ((tl or {}).get("video"), (script or {}).get("video")):
|
|
501
|
+
if not isinstance(src, dict):
|
|
502
|
+
continue
|
|
503
|
+
try:
|
|
504
|
+
ow = int(src.get("out_w") or src.get("width") or src.get("out_width") or 0)
|
|
505
|
+
oh = int(src.get("out_h") or src.get("height") or src.get("out_height") or 0)
|
|
506
|
+
except (TypeError, ValueError):
|
|
507
|
+
continue
|
|
508
|
+
if ow > 0 and oh > 0:
|
|
509
|
+
return (ow, oh)
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _judge_sizes(info, tl, script, comp_w, comp_h):
|
|
514
|
+
"""判定"成片分辨率 vs 内部构图分辨率",返回 (verdict, ref_scale, detail)。
|
|
515
|
+
|
|
516
|
+
verdict:
|
|
517
|
+
"same" 成片与构图同尺寸,直接逐帧比较
|
|
518
|
+
"scale" 成片是 16:9 下采样交付,参考流在 ffmpeg 滤镜图里 lanczos 缩到成片尺寸
|
|
519
|
+
"fail" 结构性不符:上采样 / 非 16:9 / 读不出尺寸 / 与声明交付尺寸不一致
|
|
520
|
+
|
|
521
|
+
v0.8 的合法配置是"内部构图恒 1920x1080,编码时 lanczos 缩到 1280x720/960x540"。
|
|
522
|
+
上采样不是合法交付(只会把 1080p 包装成更高分辨率),这里明确挡住。
|
|
523
|
+
"""
|
|
524
|
+
fw, fh = info.get("width"), info.get("height")
|
|
525
|
+
if not fw or not fh:
|
|
526
|
+
return "fail", None, ("读不出成片分辨率(ffprobe/filmkit.probe 都没给出宽高):"
|
|
527
|
+
"无法确认成片与内部构图 %dx%d 的关系" % (comp_w, comp_h))
|
|
528
|
+
fw, fh = int(fw), int(fh)
|
|
529
|
+
# 先对"声明的交付规格":成片尺寸必须与 timeline.video 一致,否则说明规格被改过
|
|
530
|
+
# (拿错成片 / 手动替换过文件),此时后面任何比较都不可信。
|
|
531
|
+
declared = _declared_out_size(tl, script)
|
|
532
|
+
if declared and declared != (fw, fh):
|
|
533
|
+
return "fail", None, (
|
|
534
|
+
"成片分辨率 %dx%d 与 timeline.video 声明的交付尺寸 %dx%d 不一致:"
|
|
535
|
+
"规格被改动过(或是拿错了成片),不能按该时间轴验收"
|
|
536
|
+
% (fw, fh, declared[0], declared[1]))
|
|
537
|
+
if fw >= comp_w and fh >= comp_h:
|
|
538
|
+
if (fw, fh) == (comp_w, comp_h):
|
|
539
|
+
return "same", None, "%dx%d" % (comp_w, comp_h)
|
|
540
|
+
return "fail", None, (
|
|
541
|
+
"成片分辨率 %dx%d 大于内部构图 %dx%d:**上采样不是合法交付**——"
|
|
542
|
+
"上采样只会把 1080p 的清晰度包装成更高分辨率(假 2K/4K)。"
|
|
543
|
+
"要更高分辨率请改内部构图,不要把成片放大后交付" % (fw, fh, comp_w, comp_h))
|
|
544
|
+
if fw > comp_w or fh > comp_h:
|
|
545
|
+
return "fail", None, (
|
|
546
|
+
"成片分辨率 %dx%d 与内部构图 %dx%d 交叉(一维大、一维小):"
|
|
547
|
+
"没有任何合法的渲染路径会产生这个尺寸" % (fw, fh, comp_w, comp_h))
|
|
548
|
+
if abs(fw / float(fh) - COMPARED_ASPECT) > ASPECT_TOL:
|
|
549
|
+
return "fail", None, (
|
|
550
|
+
"成片分辨率 %dx%d 不是 16:9(%.3f:1):本项目只支持 16:9 横屏的缩放交付;"
|
|
551
|
+
"非 16:9 说明成片被裁切/变形了,不能当合格交付"
|
|
552
|
+
% (fw, fh, fw / float(fh)))
|
|
553
|
+
return ("scale", {"from": [comp_w, comp_h], "to": [fw, fh]},
|
|
554
|
+
"参考流 %dx%d lanczos -> 成片 %dx%d" % (comp_w, comp_h, fw, fh))
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _prepare_script(script, tl):
|
|
558
|
+
"""返回 (script_for_draw, theme):清理出与 render.py frames 阶段同一份输入。
|
|
559
|
+
|
|
560
|
+
- script["_total"] = tl["total"](场景里的进度条/HUD 依赖它)
|
|
561
|
+
- script["_style"] = 风格计划(scenes.py 自己会读它决定配色)
|
|
562
|
+
- theme:字幕主题(legacy = None)
|
|
563
|
+
"""
|
|
564
|
+
sc = dict(script) # 浅拷贝:不改调用方的对象
|
|
565
|
+
sc["_total"] = float(_f(tl.get("total"), 0.0))
|
|
566
|
+
plan = sc.get("_style")
|
|
567
|
+
if not isinstance(plan, dict):
|
|
568
|
+
plan = _style_plan(sc)
|
|
569
|
+
sc["_style"] = plan
|
|
570
|
+
legacy = bool((plan or {}).get("legacy"))
|
|
571
|
+
if legacy:
|
|
572
|
+
return sc, None
|
|
573
|
+
theme = _theme_of(plan)
|
|
574
|
+
if theme is None:
|
|
575
|
+
raise LookupError("非 legacy 剧本解析不到主题(stylekit.THEMES 缺 %r)"
|
|
576
|
+
% ((plan or {}).get("theme_id"),))
|
|
577
|
+
return sc, theme
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _draw_subtitle_like_render(frame, text, theme, words):
|
|
581
|
+
"""烧字幕,与 render.py 同口径(v0.8 起带 words 词边界折行)。
|
|
582
|
+
|
|
583
|
+
⚠ 参考帧为什么不传 words 是个**会误杀好片**的缺陷(实测):
|
|
584
|
+
render.py 的 frames / stills 阶段调 draw_subtitle 时总会传 words,
|
|
585
|
+
字幕会按词边界折行("…整整 / 一句旁白");参考帧不传就退回字符折行
|
|
586
|
+
("…整整一 / 句旁白")。38 字字幕只差一个断点、约 2% 像素不同,
|
|
587
|
+
但近静态画面的 VMAF 从 99.95 掉到 8.55 —— 门禁会把同源的好片判 fail。
|
|
588
|
+
老版 filmkit 的 draw_subtitle 没有 words 形参,TypeError 时退回旧调用。
|
|
589
|
+
"""
|
|
590
|
+
if not words:
|
|
591
|
+
return _K.draw_subtitle(frame, text, theme=theme)
|
|
592
|
+
try:
|
|
593
|
+
return _K.draw_subtitle(frame, text, theme=theme, words=words)
|
|
594
|
+
except TypeError: # 老 filmkit:没有 words 形参(保持旧行为)
|
|
595
|
+
return _K.draw_subtitle(frame, text, theme=theme)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _draw_ref_frame(scenes, scenes_mod, script, theme, fi, fps, total):
|
|
599
|
+
"""渲染第 fi 帧参考图(RGB)。逐行复刻 render.py:_render_range 的 859–885 行。
|
|
600
|
+
|
|
601
|
+
这一步是门禁的"参考系":任何一行不一致(字幕、淡入淡出、wash 色)都会让
|
|
602
|
+
VMAF 系统性偏低。改 render.py 的绘制顺序时必须同步改这里。
|
|
603
|
+
"""
|
|
604
|
+
t = int(fi) / float(fps)
|
|
605
|
+
idx = 0
|
|
606
|
+
n = len(scenes)
|
|
607
|
+
while idx + 1 < n and t >= scenes[idx + 1]["start"]:
|
|
608
|
+
idx += 1
|
|
609
|
+
s = scenes[idx]
|
|
610
|
+
lt = t - s["start"]
|
|
611
|
+
frame = scenes_mod.draw_frame(s, lt, s["dur"], t, script, int(fi))
|
|
612
|
+
if frame is None:
|
|
613
|
+
raise RuntimeError("场景 %s 在 t=%.2fs 返回 None" % (s.get("id"), t))
|
|
614
|
+
if not isinstance(frame, Image.Image):
|
|
615
|
+
raise RuntimeError("场景 %s 的 draw_frame 没有返回 PIL.Image(收到 %s)"
|
|
616
|
+
% (s.get("id"), type(frame).__name__))
|
|
617
|
+
if frame.mode != "RGB":
|
|
618
|
+
frame = frame.convert("RGB")
|
|
619
|
+
hold = script.get("sub_max_hold")
|
|
620
|
+
text = _K.sub_at(scenes, t, hold)
|
|
621
|
+
if text:
|
|
622
|
+
# 词表取法与 render.py 一致(老 filmkit 没有 sub_words_at 时返回 None)
|
|
623
|
+
fn = getattr(_K, "sub_words_at", None)
|
|
624
|
+
try:
|
|
625
|
+
words = fn(scenes, t, hold) if fn is not None else None
|
|
626
|
+
except Exception: # noqa: BLE001 —— 词表异常不该让参考帧渲不出来
|
|
627
|
+
words = None
|
|
628
|
+
frame = _draw_subtitle_like_render(frame, text, theme, words)
|
|
629
|
+
fade = 1.0
|
|
630
|
+
if t < 0.5:
|
|
631
|
+
fade *= _K.ease_out(t / 0.5)
|
|
632
|
+
if t > total - 0.8:
|
|
633
|
+
fade *= max(0.0, (total - t) / 0.8)
|
|
634
|
+
if fade < 0.999:
|
|
635
|
+
wipe = (0, 0, 0)
|
|
636
|
+
if theme is not None and str(theme.get("transition") or "black") == "wash":
|
|
637
|
+
wipe = tuple(theme["bg2"])
|
|
638
|
+
frame = Image.blend(Image.new("RGB", frame.size, wipe), frame, fade)
|
|
639
|
+
return frame
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ---------------------------------------------------------------- 比较(一次 ffmpeg 调用)
|
|
643
|
+
def _build_filtergraph(fps, step, threads, has_ssim, vmaf_log, ssim_log, dist_fps=None,
|
|
644
|
+
ref_scale=None):
|
|
645
|
+
"""构造 filter_complex:参考流从 stdin 的 rawvideo 来,成片走解码流,秒对齐。
|
|
646
|
+
|
|
647
|
+
关键点:
|
|
648
|
+
- 两侧都 settb=AVTB + setpts=N/(fps*TB):把时间戳重排成 0,1,2… 的等间隔序列,
|
|
649
|
+
否则 rawvideo(1/30)与 h264 解码(1/15360)的 timebase 不一致,
|
|
650
|
+
libvmaf/ssim 的 framesync 会按"最近的下界时间戳"凑对,容易错帧。
|
|
651
|
+
- 抽样(step>1)只对成片做 select,参考侧本来只喂抽中的帧,两边帧数天然相等。
|
|
652
|
+
- split=2:**只渲染一遍**参考帧,同时喂 VMAF 与 SSIM 两个滤镜。
|
|
653
|
+
- ref_scale:v0.8 下采样交付时,把参考流用 lanczos 缩到成片分辨率再比——
|
|
654
|
+
与 filmkit.FrameSink 编码时的 -vf scale=out_w:out_h:flags=lanczos 同口径
|
|
655
|
+
(不在 PIL 侧缩,避免两套插值口径混用)。
|
|
656
|
+
"""
|
|
657
|
+
tb = "settb=expr=AVTB,setpts=N/(%.6g*TB)" % float(fps)
|
|
658
|
+
ref = "[0:v]"
|
|
659
|
+
if ref_scale:
|
|
660
|
+
ref += "scale=%d:%d:flags=lanczos," % (int(ref_scale["to"][0]),
|
|
661
|
+
int(ref_scale["to"][1]))
|
|
662
|
+
ref += "format=yuv420p,%s,split=2[ra][rb]" % tb
|
|
663
|
+
d = ""
|
|
664
|
+
if dist_fps and abs(float(dist_fps) - float(fps)) > 1e-3:
|
|
665
|
+
d += "fps=%.6g," % float(fps) # 帧率不一致时先折算到时间轴帧率
|
|
666
|
+
if int(step) > 1:
|
|
667
|
+
d += "select='not(mod(n\\,%d))'," % int(step)
|
|
668
|
+
d += "format=yuv420p,%s,split=2[da][db]" % tb
|
|
669
|
+
dist = "[1:v]" + d
|
|
670
|
+
vmaf = ("[da][ra]libvmaf=log_path=%s:log_fmt=json:n_threads=%d:"
|
|
671
|
+
"shortest=1:eof_action=endall[vout]" % (vmaf_log, int(threads)))
|
|
672
|
+
parts = [ref, dist, vmaf]
|
|
673
|
+
if has_ssim:
|
|
674
|
+
parts.append("[db][rb]ssim=stats_file=%s:shortest=1:eof_action=endall[sout]" % ssim_log)
|
|
675
|
+
return ";".join(parts)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _read_vmaf(path):
|
|
679
|
+
"""解析 libvmaf JSON:返回 {mean,min,max,harmonic_mean,frames,per_frame[],version}。"""
|
|
680
|
+
try:
|
|
681
|
+
with open(path, encoding="utf-8") as fp:
|
|
682
|
+
data = json.load(fp)
|
|
683
|
+
except (OSError, json.JSONDecodeError):
|
|
684
|
+
return None
|
|
685
|
+
pooled = ((data.get("pooled_metrics") or {}).get("vmaf") or {})
|
|
686
|
+
if not pooled:
|
|
687
|
+
return None
|
|
688
|
+
per = []
|
|
689
|
+
for fr in (data.get("frames") or []):
|
|
690
|
+
v = ((fr.get("metrics") or {}).get("vmaf"))
|
|
691
|
+
if _finite(v):
|
|
692
|
+
per.append(float(v))
|
|
693
|
+
if not per:
|
|
694
|
+
return None
|
|
695
|
+
return {"mean": _f(pooled.get("mean"), float(np.mean(per))),
|
|
696
|
+
"min": _f(pooled.get("min"), float(np.min(per))),
|
|
697
|
+
"max": _f(pooled.get("max"), float(np.max(per))),
|
|
698
|
+
"harmonic_mean": _f(pooled.get("harmonic_mean"), float(np.mean(per))),
|
|
699
|
+
"frames": len(per), "per_frame": per,
|
|
700
|
+
"version": str(data.get("version") or "")}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
_SSIM_RE = re.compile(r"All:([0-9.]+)")
|
|
704
|
+
_SSIM_Y_RE = re.compile(r"Y:([0-9.]+)")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _read_ssim(path):
|
|
708
|
+
"""解析 ssim 的 stats_file:每行一帧 "n:1 Y:0.99 U:.. V:.. All:0.98 (13.7)"。"""
|
|
709
|
+
try:
|
|
710
|
+
with open(path, encoding="utf-8", errors="replace") as fp:
|
|
711
|
+
lines = fp.read().splitlines()
|
|
712
|
+
except OSError:
|
|
713
|
+
return None
|
|
714
|
+
alls, ys = [], []
|
|
715
|
+
for ln in lines:
|
|
716
|
+
m = _SSIM_RE.search(ln)
|
|
717
|
+
if m:
|
|
718
|
+
try:
|
|
719
|
+
alls.append(float(m.group(1)))
|
|
720
|
+
except ValueError:
|
|
721
|
+
continue
|
|
722
|
+
m2 = _SSIM_Y_RE.search(ln)
|
|
723
|
+
if m2:
|
|
724
|
+
try:
|
|
725
|
+
ys.append(float(m2.group(1)))
|
|
726
|
+
except ValueError:
|
|
727
|
+
pass
|
|
728
|
+
if not alls:
|
|
729
|
+
return None
|
|
730
|
+
return {"mean": float(np.mean(alls)), "min": float(np.min(alls)),
|
|
731
|
+
"y": float(np.mean(ys)) if ys else None, "frames": len(alls)}
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ---------------------------------------------------------------- 同源抽查
|
|
735
|
+
def _decode_gray_frame(film, t):
|
|
736
|
+
"""精确解码成片 t 秒处的那一帧,缩到 SPOT_W x SPOT_H 灰度 float32(0-1)。"""
|
|
737
|
+
cmd = [_ffmpeg(), "-v", "error", "-ss", "%.6f" % max(0.0, float(t)), "-i", str(film),
|
|
738
|
+
"-frames:v", "1", "-vf", "scale=%d:%d:flags=area" % (SPOT_W, SPOT_H),
|
|
739
|
+
"-an", "-sn", "-f", "rawvideo", "-pix_fmt", "gray", "pipe:1"]
|
|
740
|
+
p = _run(cmd)
|
|
741
|
+
need = SPOT_W * SPOT_H
|
|
742
|
+
if p.returncode != 0 or len(p.stdout or b"") < need:
|
|
743
|
+
return None
|
|
744
|
+
arr = np.frombuffer(p.stdout[:need], dtype=np.uint8).reshape(SPOT_H, SPOT_W)
|
|
745
|
+
return arr.astype(np.float32) * (1.0 / 255.0)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _spot_check(film, ref_img, t, ref_scale=None):
|
|
749
|
+
"""参考帧 vs 同刻成片帧的 MAE:用来发现"参考帧和成片不是同源"的漂移。
|
|
750
|
+
|
|
751
|
+
只是漂移探测(缩到 160x90 灰度),这里用 PIL 缩放无妨;VMAF/SSIM 的正式
|
|
752
|
+
比较一定走 ffmpeg 滤镜图(见 _build_filtergraph 的 ref_scale 分支)。
|
|
753
|
+
"""
|
|
754
|
+
got = _decode_gray_frame(film, t)
|
|
755
|
+
if got is None:
|
|
756
|
+
return None
|
|
757
|
+
img = ref_img
|
|
758
|
+
if ref_scale:
|
|
759
|
+
img = img.resize((int(ref_scale["to"][0]), int(ref_scale["to"][1])), Image.LANCZOS)
|
|
760
|
+
small = img.convert("L").resize((SPOT_W, SPOT_H), Image.BOX)
|
|
761
|
+
ref = np.asarray(small, dtype=np.float32) * (1.0 / 255.0)
|
|
762
|
+
return {"t": round(float(t), 3), "mae": round(float(np.mean(np.abs(ref - got))), 5)}
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# ---------------------------------------------------------------- 报告
|
|
766
|
+
def _metric(pass_, warn_):
|
|
767
|
+
return {"score": None, "pass": float(pass_), "warn": float(warn_),
|
|
768
|
+
"status": "skipped", "detail": "未执行", "min": None}
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _grade(score, pass_, warn_) -> str:
|
|
772
|
+
if score < float(pass_):
|
|
773
|
+
return "fail"
|
|
774
|
+
if score < float(warn_):
|
|
775
|
+
return "warn"
|
|
776
|
+
return "pass"
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def report(film_mp4, tl, scenes_mod, script, *, mode="full", out_json=None,
|
|
780
|
+
vmaf_pass=VMAF_PASS, vmaf_warn=VMAF_WARN,
|
|
781
|
+
ssim_pass=SSIM_PASS, ssim_warn=SSIM_WARN,
|
|
782
|
+
limit_seconds=None, sample_step=None, threads=DEFAULT_THREADS,
|
|
783
|
+
log=print) -> dict:
|
|
784
|
+
"""全片 VMAF/SSIM 门禁(详见模块 docstring 的"契约"与"三档语义")。"""
|
|
785
|
+
t_start = time.perf_counter()
|
|
786
|
+
log = log or (lambda *_a, **_k: None)
|
|
787
|
+
mode = str(mode or "full").strip().lower()
|
|
788
|
+
if mode not in ("full", "sample"):
|
|
789
|
+
raise ValueError("mode 只能是 full / sample(收到 %r)" % (mode,))
|
|
790
|
+
step = int(sample_step) if sample_step else (1 if mode == "full" else SAMPLE_STEP)
|
|
791
|
+
step = max(1, int(step))
|
|
792
|
+
|
|
793
|
+
rep = {"judged": False, "mode": mode,
|
|
794
|
+
"vmaf": _metric(vmaf_pass, vmaf_warn),
|
|
795
|
+
"ssim": _metric(ssim_pass, ssim_warn),
|
|
796
|
+
"frames_compared": 0, "elapsed_s": 0.0,
|
|
797
|
+
"warnings": [], "errors": [],
|
|
798
|
+
"gate": {"status": "skipped", "detail": ""},
|
|
799
|
+
"film": str(film_mp4 or ""), "sample_step": step,
|
|
800
|
+
"limit_seconds": limit_seconds, "version": __version__}
|
|
801
|
+
warnings = rep["warnings"]
|
|
802
|
+
errors = rep["errors"]
|
|
803
|
+
|
|
804
|
+
if float(vmaf_pass) > float(vmaf_warn):
|
|
805
|
+
warnings.append("VMAF 阈值顺序异常:vmaf_pass=%.2f > vmaf_warn=%.2f,"
|
|
806
|
+
"warning 区间为空(只按 fail 线判定)" % (vmaf_pass, vmaf_warn))
|
|
807
|
+
if float(ssim_pass) > float(ssim_warn):
|
|
808
|
+
warnings.append("SSIM 阈值顺序异常:ssim_pass=%.3f > ssim_warn=%.3f(只按 fail 线判定)"
|
|
809
|
+
% (ssim_pass, ssim_warn))
|
|
810
|
+
|
|
811
|
+
def _skip(reason):
|
|
812
|
+
"""没查成:skipped + 写明原因(绝不冒充 pass)。"""
|
|
813
|
+
detail = "未执行:%s" % reason
|
|
814
|
+
rep["gate"] = {"status": "skipped", "detail": detail}
|
|
815
|
+
rep["vmaf"].update(status="skipped", detail=detail)
|
|
816
|
+
rep["ssim"].update(status="skipped", detail=detail)
|
|
817
|
+
warnings.append("VMAF/SSIM 门禁未执行(三档语义 = skipped,不是通过):%s" % reason)
|
|
818
|
+
rep["elapsed_s"] = round(time.perf_counter() - t_start, 2)
|
|
819
|
+
_finish(rep, out_json)
|
|
820
|
+
return rep
|
|
821
|
+
|
|
822
|
+
# ---- 1. 输入 ----
|
|
823
|
+
film = str(film_mp4 or "")
|
|
824
|
+
if not film or not os.path.exists(film):
|
|
825
|
+
return _skip("成片不存在:%s" % film)
|
|
826
|
+
film = os.path.abspath(film)
|
|
827
|
+
rep["film"] = film
|
|
828
|
+
if _K is None:
|
|
829
|
+
return _skip("缺同目录 filmkit.py(参考帧要它的 W/H、字幕绘制与 ffmpeg 探测),"
|
|
830
|
+
"无法复刻 frames 阶段")
|
|
831
|
+
try:
|
|
832
|
+
tl = _as_timeline(tl)
|
|
833
|
+
except ValueError as e:
|
|
834
|
+
return _skip(str(e))
|
|
835
|
+
try:
|
|
836
|
+
scenes_mod = _as_scenes(scenes_mod)
|
|
837
|
+
except Exception as e: # noqa: BLE001
|
|
838
|
+
return _skip("场景模块加载失败:%s" % e)
|
|
839
|
+
try:
|
|
840
|
+
script = _as_script(script)
|
|
841
|
+
except ValueError as e:
|
|
842
|
+
return _skip(str(e))
|
|
843
|
+
|
|
844
|
+
scenes = tl.get("scenes") or []
|
|
845
|
+
if not scenes:
|
|
846
|
+
return _skip("时间轴里没有 scenes(timeline.json 的 scenes 为空)")
|
|
847
|
+
fps = _f(tl.get("fps"), 0.0) or _f(getattr(_K, "FPS", 30), 30.0)
|
|
848
|
+
if not _finite(fps) or fps <= 0:
|
|
849
|
+
return _skip("时间轴 fps 非法:%r" % (tl.get("fps"),))
|
|
850
|
+
total = _f(tl.get("total"), 0.0)
|
|
851
|
+
if total <= 0:
|
|
852
|
+
return _skip("时间轴 total 非法:%r" % (tl.get("total"),))
|
|
853
|
+
frames = int(round(_f(tl.get("frames"), 0.0) or total * fps))
|
|
854
|
+
if frames <= 0:
|
|
855
|
+
return _skip("时间轴 frames 非法:%r" % (tl.get("frames"),))
|
|
856
|
+
|
|
857
|
+
# ---- 2. ffmpeg 能力(fail-closed:找不到 = skipped) ----
|
|
858
|
+
try:
|
|
859
|
+
ff = _ffmpeg()
|
|
860
|
+
except ValueError as e:
|
|
861
|
+
return _skip(str(e))
|
|
862
|
+
caps = _capabilities(ff)
|
|
863
|
+
if not caps.get("libvmaf"):
|
|
864
|
+
return _skip("ffmpeg 构建不含 libvmaf 滤镜(%s):无法计算 VMAF,本次未检查画质"
|
|
865
|
+
% ff)
|
|
866
|
+
has_ssim = bool(caps.get("ssim"))
|
|
867
|
+
if not has_ssim:
|
|
868
|
+
warnings.append("ffmpeg 构建不含 ssim 滤镜,本次只算 VMAF(SSIM 记为 skipped)")
|
|
869
|
+
rep["ffmpeg"] = ff
|
|
870
|
+
|
|
871
|
+
# ---- 3. 成片探测 + 结构性校验(含 v0.8 下采样交付) ----
|
|
872
|
+
info = _probe_film(film)
|
|
873
|
+
rep["film_info"] = info
|
|
874
|
+
W, H = int(getattr(_K, "W", 1920)), int(getattr(_K, "H", 1080))
|
|
875
|
+
verdict, ref_scale, size_detail = _judge_sizes(info, tl, script, W, H)
|
|
876
|
+
rep["ref_scale"] = ref_scale
|
|
877
|
+
rep["size_verdict"] = verdict
|
|
878
|
+
if verdict == "fail":
|
|
879
|
+
rep["gate"] = {"status": "fail", "detail": size_detail}
|
|
880
|
+
rep["vmaf"].update(status="fail", detail=size_detail)
|
|
881
|
+
rep["ssim"].update(status="skipped", detail=size_detail)
|
|
882
|
+
errors.append(size_detail)
|
|
883
|
+
rep["elapsed_s"] = round(time.perf_counter() - t_start, 2)
|
|
884
|
+
_finish(rep, out_json)
|
|
885
|
+
return rep
|
|
886
|
+
if ref_scale:
|
|
887
|
+
warnings.append("成片 %dx%d 是下采样交付:参考流在 ffmpeg 滤镜图里 lanczos 缩到"
|
|
888
|
+
"成片分辨率后比较(与 FrameSink 编码口径一致);VMAF 阈值仍按 "
|
|
889
|
+
"1080p 标定,分数与 1080p 交付不完全可比"
|
|
890
|
+
% (ref_scale["to"][0], ref_scale["to"][1]))
|
|
891
|
+
|
|
892
|
+
# ---- 4. 参考帧输入(含脚本/主题准备) ----
|
|
893
|
+
try:
|
|
894
|
+
script, theme = _prepare_script(script, tl)
|
|
895
|
+
except Exception as e: # noqa: BLE001 —— 风格解析失败也算"没法复刻参考帧"
|
|
896
|
+
return _skip("参考帧准备失败(剧本风格/主题解析):%s" % e)
|
|
897
|
+
|
|
898
|
+
# ---- 5. 比较计划 ----
|
|
899
|
+
n_expect = frames
|
|
900
|
+
if limit_seconds is not None:
|
|
901
|
+
lim = _f(limit_seconds, 0.0)
|
|
902
|
+
if lim > 0:
|
|
903
|
+
n_expect = min(n_expect, int(round(lim * fps)))
|
|
904
|
+
n_film = None
|
|
905
|
+
if info.get("nb_frames"):
|
|
906
|
+
n_film = int(info["nb_frames"])
|
|
907
|
+
elif info.get("duration"):
|
|
908
|
+
n_film = int(round(float(info["duration"]) * fps))
|
|
909
|
+
if n_film and n_film < n_expect:
|
|
910
|
+
warnings.append("成片只有 %d 帧(时间轴 %d 帧):只比较两者都能对上的前 %d 帧"
|
|
911
|
+
% (n_film, frames, n_film))
|
|
912
|
+
n_expect = max(0, n_film)
|
|
913
|
+
if n_expect <= 0:
|
|
914
|
+
return _skip("没有可比较的帧(时间轴 %d 帧 / 成片 %r 帧)" % (frames, n_film))
|
|
915
|
+
indices = list(range(0, n_expect, step))
|
|
916
|
+
rep["frames_expected"] = len(indices)
|
|
917
|
+
rep["frames_timeline"] = frames
|
|
918
|
+
rep["film_fps"] = info.get("fps")
|
|
919
|
+
|
|
920
|
+
dist_fps = None
|
|
921
|
+
if info.get("fps"):
|
|
922
|
+
dist_fps = float(info["fps"])
|
|
923
|
+
if abs(dist_fps - fps) > 1e-3:
|
|
924
|
+
warnings.append("成片帧率 %.6g 与时间轴 %.6g 不一致:成片侧用 fps 滤镜重采样后比较"
|
|
925
|
+
"(逐帧对齐已不可保证,分数仅供参考)" % (dist_fps, fps))
|
|
926
|
+
if step > 1:
|
|
927
|
+
warnings.append("sample 模式:每 %d 帧取 1 帧(共 %d 帧),结论不代表全片逐帧画质"
|
|
928
|
+
% (step, len(indices)))
|
|
929
|
+
if limit_seconds is not None and _f(limit_seconds, 0) > 0:
|
|
930
|
+
warnings.append("--limit-seconds=%.2fs:只比较了前 %.2fs,结论不代表全片"
|
|
931
|
+
% (_f(limit_seconds, 0), n_expect / float(fps)))
|
|
932
|
+
|
|
933
|
+
# ---- 6. 一次 ffmpeg 调用:边渲参考帧边比较 ----
|
|
934
|
+
tmpdir = tempfile.mkdtemp(prefix="qavmaf_")
|
|
935
|
+
vmaf_log = "vmaf.json"
|
|
936
|
+
ssim_log = "ssim.log"
|
|
937
|
+
graph = _build_filtergraph(fps, step, threads, has_ssim, vmaf_log, ssim_log,
|
|
938
|
+
dist_fps=dist_fps, ref_scale=ref_scale)
|
|
939
|
+
tlim = n_expect / float(fps) + (1.0 / float(fps)) # 多给一帧余量,避免舍入丢尾帧
|
|
940
|
+
cmd = [ff, "-hide_banner", "-nostats", "-v", "error",
|
|
941
|
+
"-f", "rawvideo", "-pix_fmt", "rgb24", "-s", "%dx%d" % (W, H),
|
|
942
|
+
"-r", "%g" % float(fps), "-i", "pipe:0",
|
|
943
|
+
"-t", "%.6f" % tlim, "-i", film,
|
|
944
|
+
"-filter_complex", graph,
|
|
945
|
+
"-map", "[vout]", "-f", "null", "-"]
|
|
946
|
+
if has_ssim:
|
|
947
|
+
cmd += ["-map", "[sout]", "-f", "null", "-"]
|
|
948
|
+
|
|
949
|
+
spot_i = indices[len(indices) // 2]
|
|
950
|
+
state = {"rendered": 0, "error": "", "spot": None}
|
|
951
|
+
render_s = 0.0
|
|
952
|
+
rc = -1
|
|
953
|
+
err_txt = ""
|
|
954
|
+
errf = tempfile.TemporaryFile()
|
|
955
|
+
try:
|
|
956
|
+
try:
|
|
957
|
+
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
|
|
958
|
+
stderr=errf, cwd=tmpdir)
|
|
959
|
+
except OSError as e:
|
|
960
|
+
return _skip("启动 ffmpeg 失败:%s" % e)
|
|
961
|
+
t_r0 = time.perf_counter()
|
|
962
|
+
report_every = max(1, len(indices) // 10)
|
|
963
|
+
try:
|
|
964
|
+
for i, fi in enumerate(indices):
|
|
965
|
+
frame = _draw_ref_frame(scenes, scenes_mod, script, theme, fi, fps, total)
|
|
966
|
+
if fi == spot_i:
|
|
967
|
+
state["spot"] = frame.copy()
|
|
968
|
+
try:
|
|
969
|
+
proc.stdin.write(frame.tobytes())
|
|
970
|
+
except (BrokenPipeError, OSError):
|
|
971
|
+
state["error"] = "ffmpeg 提前退出(参考帧管道已关闭),见 stderr"
|
|
972
|
+
break
|
|
973
|
+
state["rendered"] = i + 1
|
|
974
|
+
if (i + 1) % report_every == 0 or (i + 1) == len(indices):
|
|
975
|
+
el = max(1e-6, time.perf_counter() - t_r0)
|
|
976
|
+
done = i + 1
|
|
977
|
+
eta = (len(indices) - done) / max(done / el, 1e-6)
|
|
978
|
+
log(" 参考帧 %d/%d %.1f 帧/秒 eta %.0fs"
|
|
979
|
+
% (done, len(indices), done / el, eta))
|
|
980
|
+
except Exception as e: # noqa: BLE001 —— 渲染/尺寸/字体错误都在这
|
|
981
|
+
bad = indices[min(state["rendered"], len(indices) - 1)]
|
|
982
|
+
state["error"] = "参考帧渲染失败(第 %d 帧 / t=%.3fs):%s" % (
|
|
983
|
+
bad, bad / float(fps), e)
|
|
984
|
+
finally:
|
|
985
|
+
render_s = time.perf_counter() - t_r0
|
|
986
|
+
try:
|
|
987
|
+
proc.stdin.close()
|
|
988
|
+
except Exception: # noqa: BLE001
|
|
989
|
+
pass
|
|
990
|
+
rc = proc.wait()
|
|
991
|
+
try:
|
|
992
|
+
errf.seek(0)
|
|
993
|
+
err_txt = errf.read().decode("utf-8", "replace")
|
|
994
|
+
except Exception: # noqa: BLE001
|
|
995
|
+
err_txt = ""
|
|
996
|
+
errf.close()
|
|
997
|
+
finally:
|
|
998
|
+
# 双保险:异常路径也不能泄漏临时 stderr 文件(重复 close 是 no-op)
|
|
999
|
+
try:
|
|
1000
|
+
errf.close()
|
|
1001
|
+
except Exception: # noqa: BLE001
|
|
1002
|
+
pass
|
|
1003
|
+
|
|
1004
|
+
try:
|
|
1005
|
+
vj = _read_vmaf(os.path.join(tmpdir, vmaf_log))
|
|
1006
|
+
ss = _read_ssim(os.path.join(tmpdir, ssim_log)) if has_ssim else None
|
|
1007
|
+
spot = None
|
|
1008
|
+
if state["spot"] is not None:
|
|
1009
|
+
try:
|
|
1010
|
+
spot = _spot_check(film, state["spot"], spot_i / float(fps),
|
|
1011
|
+
ref_scale=ref_scale)
|
|
1012
|
+
except Exception as e: # noqa: BLE001
|
|
1013
|
+
spot = None
|
|
1014
|
+
warnings.append("同源抽查失败(不影响门禁):%s" % e)
|
|
1015
|
+
finally:
|
|
1016
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
1017
|
+
|
|
1018
|
+
rep["render_s"] = round(render_s, 2)
|
|
1019
|
+
rep["frames_rendered"] = int(state["rendered"])
|
|
1020
|
+
if spot:
|
|
1021
|
+
rep["spot_check"] = spot
|
|
1022
|
+
if state["error"]:
|
|
1023
|
+
# 参考帧没渲完 = 后半段"没查":一律 skipped,不给"只查了一半"的 pass。
|
|
1024
|
+
return _skip("%s(已渲染 %d/%d 帧)" % (state["error"], state["rendered"],
|
|
1025
|
+
len(indices)))
|
|
1026
|
+
if rc != 0:
|
|
1027
|
+
return _skip("ffmpeg 比较失败(rc=%d):%s" % (rc, _tail(err_txt, 500)))
|
|
1028
|
+
|
|
1029
|
+
frames_compared = 0
|
|
1030
|
+
if vj:
|
|
1031
|
+
frames_compared = int(vj["frames"])
|
|
1032
|
+
elif ss:
|
|
1033
|
+
frames_compared = int(ss["frames"])
|
|
1034
|
+
rep["frames_compared"] = frames_compared
|
|
1035
|
+
if frames_compared <= 0:
|
|
1036
|
+
return _skip("比较了 0 帧(成片被 -t 截断?%s)" % (_tail(err_txt, 300) or "无 stderr"))
|
|
1037
|
+
|
|
1038
|
+
# ---- 7. 判定 ----
|
|
1039
|
+
rep["coverage"] = round(frames_compared / float(len(indices)), 4)
|
|
1040
|
+
if frames_compared < len(indices):
|
|
1041
|
+
warnings.append("实际比较 %d 帧 < 计划 %d 帧(成片提前结束或帧率折算),"
|
|
1042
|
+
"覆盖率 %.1f%%" % (frames_compared, len(indices),
|
|
1043
|
+
rep["coverage"] * 100.0))
|
|
1044
|
+
|
|
1045
|
+
vscore = float(vj["mean"]) if vj else None
|
|
1046
|
+
vmin = float(vj["min"]) if vj else None
|
|
1047
|
+
if vj:
|
|
1048
|
+
per = np.asarray(vj["per_frame"], dtype=np.float64)
|
|
1049
|
+
p5 = float(np.percentile(per, 5)) if per.size else vmin
|
|
1050
|
+
status = _grade(vscore, vmaf_pass, vmaf_warn)
|
|
1051
|
+
rep["vmaf"].update(score=round(vscore, 3), min=round(vmin, 3),
|
|
1052
|
+
p5=round(p5, 3), max=round(float(vj["max"]), 3),
|
|
1053
|
+
harmonic_mean=round(float(vj["harmonic_mean"]), 3),
|
|
1054
|
+
frames=frames_compared, status=status,
|
|
1055
|
+
detail="VMAF 均值 %.2f(min %.2f / p5 %.2f),阈值 pass≥%.1f / warn≥%.1f"
|
|
1056
|
+
% (vscore, vmin, p5, vmaf_pass, vmaf_warn),
|
|
1057
|
+
model={"version": vj.get("version") or "",
|
|
1058
|
+
"note": "libvmaf 默认模型 vmaf_v0.6.1(ffmpeg 内置)"})
|
|
1059
|
+
else:
|
|
1060
|
+
return _skip("libvmaf 没有产出逐帧分数(log 文件缺失或为空)")
|
|
1061
|
+
|
|
1062
|
+
sscore = float(ss["mean"]) if ss else None
|
|
1063
|
+
if ss:
|
|
1064
|
+
status_s = _grade(sscore, ssim_pass, ssim_warn)
|
|
1065
|
+
rep["ssim"].update(score=round(sscore, 5), min=round(float(ss["min"]), 5),
|
|
1066
|
+
y=round(float(ss["y"]), 5) if ss.get("y") is not None else None,
|
|
1067
|
+
frames=ss["frames"], status=status_s,
|
|
1068
|
+
detail="SSIM 均值 %.4f(min %.4f / Y %.4f),诊断阈值 pass≥%.3f"
|
|
1069
|
+
% (sscore, ss["min"], ss.get("y") or 0.0, ssim_pass))
|
|
1070
|
+
else:
|
|
1071
|
+
rep["ssim"].update(status="skipped",
|
|
1072
|
+
detail="ssim 未产出统计(ffmpeg 构建缺 ssim 或统计文件为空)")
|
|
1073
|
+
|
|
1074
|
+
# ---- 8. 汇总 ----
|
|
1075
|
+
gate_status = "fail" if vscore < float(vmaf_pass) else "pass"
|
|
1076
|
+
if gate_status == "fail":
|
|
1077
|
+
errors.append("VMAF 均值 %.2f < 失败阈值 %.1f(画面已明显劣化:检查 crf/preset/"
|
|
1078
|
+
"是否二次编码/是否缩放插值)" % (vscore, vmaf_pass))
|
|
1079
|
+
elif vscore < float(vmaf_warn):
|
|
1080
|
+
warnings.append("VMAF 均值 %.2f 通过(≥%.1f),但低于建议阈值 %.1f:"
|
|
1081
|
+
"建议复核 crf/preset 与画面复杂度" % (vscore, vmaf_pass, vmaf_warn))
|
|
1082
|
+
if sscore is not None:
|
|
1083
|
+
if sscore < float(ssim_pass):
|
|
1084
|
+
warnings.append("SSIM 均值 %.4f < %.3f:结构相似度偏低(VMAF 为准,此项仅诊断)"
|
|
1085
|
+
% (sscore, ssim_pass))
|
|
1086
|
+
elif sscore < float(ssim_warn):
|
|
1087
|
+
warnings.append("SSIM 均值 %.4f 低于建议值 %.3f(仅诊断,不阻断)"
|
|
1088
|
+
% (sscore, ssim_warn))
|
|
1089
|
+
if spot:
|
|
1090
|
+
if _f(spot.get("mae"), 0.0) > SPOT_WARN_MAE:
|
|
1091
|
+
warnings.append("同源抽查异常:t=%.2fs 参考帧与成片帧 MAE=%.4f > %.3f —— "
|
|
1092
|
+
"参考帧可能与成片不同源(render.py 的字幕/淡入淡出/主题改过?)"
|
|
1093
|
+
"本次 VMAF 分数可能偏低"
|
|
1094
|
+
% (spot["t"], spot["mae"], SPOT_WARN_MAE))
|
|
1095
|
+
|
|
1096
|
+
rep["judged"] = True
|
|
1097
|
+
rep["elapsed_s"] = round(time.perf_counter() - t_start, 2)
|
|
1098
|
+
rep["gate"] = {"status": gate_status,
|
|
1099
|
+
"detail": _gate_detail(rep, gate_status)}
|
|
1100
|
+
_finish(rep, out_json)
|
|
1101
|
+
return rep
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _gate_detail(rep, status) -> str:
|
|
1105
|
+
v, s = rep.get("vmaf") or {}, rep.get("ssim") or {}
|
|
1106
|
+
bits = []
|
|
1107
|
+
if status == "fail":
|
|
1108
|
+
bits.append("VMAF 均值 %.2f < 失败阈值 %.1f" % (_f(v.get("score")), _f(v.get("pass"))))
|
|
1109
|
+
else:
|
|
1110
|
+
bits.append("VMAF 均值 %.2f ≥ 失败阈值 %.1f(建议阈值 %.1f)"
|
|
1111
|
+
% (_f(v.get("score")), _f(v.get("pass")), _f(v.get("warn"))))
|
|
1112
|
+
if s.get("score") is not None:
|
|
1113
|
+
bits.append("SSIM %.4f" % _f(s.get("score")))
|
|
1114
|
+
bits.append("比较 %d/%d 帧" % (int(rep.get("frames_compared") or 0),
|
|
1115
|
+
int(rep.get("frames_expected") or 0)))
|
|
1116
|
+
if int(rep.get("sample_step") or 1) > 1:
|
|
1117
|
+
bits.append("sample 每 %d 帧取 1 帧" % int(rep.get("sample_step") or 1))
|
|
1118
|
+
bits.append("非全片结论")
|
|
1119
|
+
elif rep.get("limit_seconds"):
|
|
1120
|
+
bits.append("仅前 %.1fs" % _f(rep.get("limit_seconds")))
|
|
1121
|
+
fi = rep.get("film_info") or {}
|
|
1122
|
+
if fi.get("width"):
|
|
1123
|
+
bits.append("%dx%d" % (int(fi["width"]), int(fi["height"])))
|
|
1124
|
+
rs = rep.get("ref_scale")
|
|
1125
|
+
if rs:
|
|
1126
|
+
bits.append("参考流 %dx%d→%dx%d" % (int(rs["from"][0]), int(rs["from"][1]),
|
|
1127
|
+
int(rs["to"][0]), int(rs["to"][1])))
|
|
1128
|
+
bits.append("耗时 %.1fs(参考帧渲染 %.1fs)"
|
|
1129
|
+
% (_f(rep.get("elapsed_s")), _f(rep.get("render_s"))))
|
|
1130
|
+
return ";".join(bits)
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _finish(rep, out_json=None):
|
|
1134
|
+
"""收尾:算耗时、写 JSON(失败不掩盖主结论)。"""
|
|
1135
|
+
if not (rep.get("gate") or {}).get("status"):
|
|
1136
|
+
rep.setdefault("gate", {})["status"] = "skipped"
|
|
1137
|
+
if out_json:
|
|
1138
|
+
try:
|
|
1139
|
+
_write_json(rep, out_json)
|
|
1140
|
+
rep["out_json"] = os.path.abspath(str(out_json))
|
|
1141
|
+
except Exception as e: # noqa: BLE001
|
|
1142
|
+
rep.setdefault("warnings", []).append("报告 JSON 落盘失败:%s" % e)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _print_summary(rep, film, out_json=None):
|
|
1146
|
+
"""人读摘要(与 qa_frames 的 _print_summary 同风格)。"""
|
|
1147
|
+
g = rep.get("gate") or {}
|
|
1148
|
+
st = str(g.get("status") or "skipped")
|
|
1149
|
+
print("== qavmaf 全片画质门禁(VMAF/SSIM)")
|
|
1150
|
+
print(" 成片:", film)
|
|
1151
|
+
print(" 模式: %s%s" % (rep.get("mode"), ("(每 %d 帧取 1 帧)" % rep["sample_step"])
|
|
1152
|
+
if rep.get("mode") == "sample" else ""))
|
|
1153
|
+
v, s = rep.get("vmaf") or {}, rep.get("ssim") or {}
|
|
1154
|
+
if v.get("score") is not None:
|
|
1155
|
+
print(" VMAF: %.2f(min %.2f / p5 %.2f,%d 帧) 阈值 pass≥%.1f warn≥%.1f -> %s"
|
|
1156
|
+
% (_f(v["score"]), _f(v.get("min")), _f(v.get("p5")),
|
|
1157
|
+
int(rep.get("frames_compared") or 0), _f(v.get("pass")),
|
|
1158
|
+
_f(v.get("warn")), v.get("status")))
|
|
1159
|
+
else:
|
|
1160
|
+
print(" VMAF: 未计算")
|
|
1161
|
+
if s.get("score") is not None:
|
|
1162
|
+
print(" SSIM: %.4f(min %.4f,%d 帧,诊断项)-> %s"
|
|
1163
|
+
% (_f(s["score"]), _f(s.get("min")), int(s.get("frames") or 0), s.get("status")))
|
|
1164
|
+
else:
|
|
1165
|
+
print(" SSIM: 未计算(%s)" % (s.get("detail") or ""))
|
|
1166
|
+
rs = rep.get("ref_scale")
|
|
1167
|
+
if rs:
|
|
1168
|
+
print(" 参考流缩放: %dx%d -> %dx%d(lanczos,与编码口径一致)"
|
|
1169
|
+
% (rs["from"][0], rs["from"][1], rs["to"][0], rs["to"][1]))
|
|
1170
|
+
print(" 比较 %s/%s 帧 耗时 %.1fs(参考帧渲染 %.1fs)"
|
|
1171
|
+
% (rep.get("frames_compared"), rep.get("frames_expected"),
|
|
1172
|
+
_f(rep.get("elapsed_s")), _f(rep.get("render_s"))))
|
|
1173
|
+
for e in rep.get("errors") or []:
|
|
1174
|
+
print(" [FAIL]", e)
|
|
1175
|
+
for w in rep.get("warnings") or []:
|
|
1176
|
+
print(" [WARN]", w)
|
|
1177
|
+
tag = {"pass": "PASS", "fail": "FAIL", "skipped": "SKIPPED(未检查,不是通过)"}.get(st, st)
|
|
1178
|
+
print(" 结论:", tag, "—", g.get("detail") or "")
|
|
1179
|
+
if out_json:
|
|
1180
|
+
print(" 报告 JSON:", os.path.abspath(str(out_json)))
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
# ---------------------------------------------------------------- 自检
|
|
1184
|
+
def selftest() -> int:
|
|
1185
|
+
"""内置自检:合成片验证管线与三档语义(不联网,≤60 s)。
|
|
1186
|
+
|
|
1187
|
+
注意:这里只测"管线 + 三档语义"——参考帧与 render.py 的同源性由真实片实验
|
|
1188
|
+
(.team/ 里的 crf17/crf30 对照)保证,合成片用它自己渲染的帧编码,
|
|
1189
|
+
因此不会出现"参考帧拿成片抽帧"那种恒等于 100 的假通过(那是被测模块的硬伤,
|
|
1190
|
+
不是自检的检查项)。
|
|
1191
|
+
"""
|
|
1192
|
+
_setup_stdio()
|
|
1193
|
+
if _K is None:
|
|
1194
|
+
print("[FAIL] 自检需要同目录 filmkit.py")
|
|
1195
|
+
return 1
|
|
1196
|
+
results = []
|
|
1197
|
+
|
|
1198
|
+
def check(name, fn):
|
|
1199
|
+
try:
|
|
1200
|
+
ok, detail = fn()
|
|
1201
|
+
except Exception as e: # noqa: BLE001
|
|
1202
|
+
ok, detail = False, "异常:%s" % e
|
|
1203
|
+
results.append((name, bool(ok), detail))
|
|
1204
|
+
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name,
|
|
1205
|
+
(" —— " + detail) if detail else ""))
|
|
1206
|
+
|
|
1207
|
+
tmp = tempfile.mkdtemp(prefix="qavmaf_selftest_")
|
|
1208
|
+
try:
|
|
1209
|
+
W, H = int(_K.W), int(_K.H)
|
|
1210
|
+
n, fps = 24, 30
|
|
1211
|
+
scenes_src = '''# -*- coding: utf-8 -*-
|
|
1212
|
+
import filmkit as K
|
|
1213
|
+
from PIL import Image, ImageDraw
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def draw_frame(scene, t_local, dur, t_global, script, frame):
|
|
1217
|
+
W, H = K.W, K.H
|
|
1218
|
+
im = Image.new("RGB", (W, H), (10, 16, 28))
|
|
1219
|
+
d = ImageDraw.Draw(im)
|
|
1220
|
+
p = min(1.0, max(0.0, t_local / max(dur, 1e-6)))
|
|
1221
|
+
x = int(120 + p * (W - 480))
|
|
1222
|
+
d.rectangle([x, 260, x + 360, 760], fill=(236, 168, 64))
|
|
1223
|
+
d.rectangle([180, 120, W - 180, 190], fill=(40, 90, 140))
|
|
1224
|
+
d.text((60, 40), "qavmaf selftest %d" % frame, fill=(240, 245, 250))
|
|
1225
|
+
return im
|
|
1226
|
+
'''
|
|
1227
|
+
scenes_path = os.path.join(tmp, "scenes.py")
|
|
1228
|
+
with open(scenes_path, "w", encoding="utf-8") as fp:
|
|
1229
|
+
fp.write(scenes_src)
|
|
1230
|
+
script = {"title": "qavmaf selftest", "lines": [
|
|
1231
|
+
{"id": "l01", "text": "一", "sub": "自检"}]}
|
|
1232
|
+
scenes = [dict(script["lines"][0], start=0.0, end=n / float(fps),
|
|
1233
|
+
dur=n / float(fps), voice_start=0.0, voice_end=n / float(fps))]
|
|
1234
|
+
tl = {"total": n / float(fps), "fps": fps, "frames": n, "scenes": scenes,
|
|
1235
|
+
"video": {"out_w": W, "out_h": H, "scale": False}}
|
|
1236
|
+
# v0.8 下采样交付:内部构图仍 1920x1080,交付 1280x720(编码时 lanczos 缩)
|
|
1237
|
+
tl720 = dict(tl, video={"out_w": 1280, "out_h": 720, "scale": True})
|
|
1238
|
+
# 老 timeline(v0.7 及以前没有 video 块):只按"构图同尺寸"校验
|
|
1239
|
+
tl_legacy = {k: v for k, v in tl.items() if k != "video"}
|
|
1240
|
+
# 非法:声明 2560x1440 输出(内部构图只有 1080p,等于上采样)
|
|
1241
|
+
tl_up = dict(tl, video={"out_w": 2560, "out_h": 1440, "scale": True})
|
|
1242
|
+
mod = load_module(scenes_path, "qavmaf_selftest_scenes")
|
|
1243
|
+
script_p, theme = _prepare_script(script, tl)
|
|
1244
|
+
refs = [_draw_ref_frame(scenes, mod, script_p, theme, fi, fps, tl["total"])
|
|
1245
|
+
for fi in range(n)]
|
|
1246
|
+
|
|
1247
|
+
def _encode(path, frames, crf, preset="veryfast", out_size=None):
|
|
1248
|
+
cmd = [_ffmpeg(), "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgb24",
|
|
1249
|
+
"-s", "%dx%d" % (W, H), "-r", "%g" % fps, "-i", "-"]
|
|
1250
|
+
if out_size:
|
|
1251
|
+
# 与 filmkit.FrameSink 的 -vf scale=out_w:out_h:flags=lanczos 同口径
|
|
1252
|
+
cmd += ["-vf", "scale=%d:%d:flags=lanczos" % tuple(out_size)]
|
|
1253
|
+
cmd += ["-c:v", "libx264", "-preset", preset, "-crf", str(crf),
|
|
1254
|
+
"-pix_fmt", "yuv420p", "-g", "60", path]
|
|
1255
|
+
p = subprocess.run(cmd, input=b"".join(f.tobytes() for f in frames),
|
|
1256
|
+
capture_output=True)
|
|
1257
|
+
if p.returncode != 0:
|
|
1258
|
+
raise RuntimeError("编码失败:%s" % _tail(p.stderr.decode("utf-8", "replace"), 300))
|
|
1259
|
+
return path
|
|
1260
|
+
|
|
1261
|
+
film_ok = _encode(os.path.join(tmp, "ok.mp4"), refs, 0)
|
|
1262
|
+
film_720 = _encode(os.path.join(tmp, "ok720.mp4"), refs, 0, out_size=(1280, 720))
|
|
1263
|
+
blurred = [f.filter(ImageFilter.GaussianBlur(4)) for f in refs]
|
|
1264
|
+
film_bad = _encode(os.path.join(tmp, "bad.mp4"), blurred, 34)
|
|
1265
|
+
|
|
1266
|
+
base = dict(tl=tl, scenes_mod=mod, script=script)
|
|
1267
|
+
|
|
1268
|
+
def c_pass():
|
|
1269
|
+
rep = report(film_ok, mode="full", log=None, **base)
|
|
1270
|
+
v = rep["vmaf"]["score"]
|
|
1271
|
+
ok = rep["judged"] and rep["gate"]["status"] == "pass"
|
|
1272
|
+
return ok and v is not None and v >= 90.0, "VMAF=%s gate=%s frames=%s" % (
|
|
1273
|
+
v, rep["gate"]["status"], rep["frames_compared"])
|
|
1274
|
+
|
|
1275
|
+
def c_bad():
|
|
1276
|
+
rep = report(film_bad, mode="full", log=None, **base)
|
|
1277
|
+
v = rep["vmaf"]["score"]
|
|
1278
|
+
ok = rep["judged"] and rep["gate"]["status"] == "fail"
|
|
1279
|
+
return ok and v is not None and v < 92.0, "VMAF=%s gate=%s" % (
|
|
1280
|
+
v, rep["gate"]["status"])
|
|
1281
|
+
|
|
1282
|
+
def c_discriminate():
|
|
1283
|
+
a = report(film_ok, mode="full", log=None, **base)["vmaf"]["score"]
|
|
1284
|
+
b = report(film_bad, mode="full", log=None, **base)["vmaf"]["score"]
|
|
1285
|
+
return (a - b) > 15.0, "lossless=%.2f bad=%.2f (差 %.2f)" % (a, b, a - b)
|
|
1286
|
+
|
|
1287
|
+
def c_skip_no_scenes():
|
|
1288
|
+
rep = report(film_ok, tl, None, script, log=None)
|
|
1289
|
+
msg = " ".join(rep["warnings"])
|
|
1290
|
+
ok = (rep["gate"]["status"] == "skipped" and not rep["judged"]
|
|
1291
|
+
and "场景" in msg)
|
|
1292
|
+
return ok, rep["gate"]["detail"]
|
|
1293
|
+
|
|
1294
|
+
def c_skip_no_timeline():
|
|
1295
|
+
rep = report(film_ok, os.path.join(tmp, "nope.json"), mod, script, log=None)
|
|
1296
|
+
ok = rep["gate"]["status"] == "skipped" and not rep["judged"]
|
|
1297
|
+
return ok, rep["gate"]["detail"]
|
|
1298
|
+
|
|
1299
|
+
def c_sample():
|
|
1300
|
+
rep = report(film_ok, mode="sample", sample_step=6, log=None, **base)
|
|
1301
|
+
exp = (n + 5) // 6
|
|
1302
|
+
ok = rep["judged"] and abs(int(rep["frames_compared"]) - exp) <= 1
|
|
1303
|
+
return ok, "frames_compared=%s 计划 %d" % (rep["frames_compared"], exp)
|
|
1304
|
+
|
|
1305
|
+
def c_limit():
|
|
1306
|
+
rep = report(film_ok, mode="full", limit_seconds=n / float(fps) / 2,
|
|
1307
|
+
log=None, **base)
|
|
1308
|
+
ok = rep["judged"] and int(rep["frames_compared"]) <= n // 2 + 1
|
|
1309
|
+
any_w = any("limit-seconds" in w for w in rep["warnings"])
|
|
1310
|
+
return ok and any_w, "frames_compared=%s" % rep["frames_compared"]
|
|
1311
|
+
|
|
1312
|
+
def c_downscale_pass():
|
|
1313
|
+
# v0.8 合法配置:1920x1080 内部构图 -> 1280x720 交付,必须 pass;
|
|
1314
|
+
# 且与 1080p 同规格成片的 VMAF 差 < 3 分(阈值可比)
|
|
1315
|
+
rep = report(film_720, tl720, mod, script, mode="full", log=None)
|
|
1316
|
+
v1080 = report(film_ok, mode="full", log=None, **base)["vmaf"]["score"]
|
|
1317
|
+
rs = rep.get("ref_scale")
|
|
1318
|
+
ok = (rep["judged"] and rep["gate"]["status"] == "pass"
|
|
1319
|
+
and rs == {"from": [W, H], "to": [1280, 720]})
|
|
1320
|
+
gap = abs(float(rep["vmaf"]["score"]) - float(v1080))
|
|
1321
|
+
return ok and gap < 3.0, ("720p VMAF=%.2f vs 1080p=%.2f 差=%.2f ref_scale=%s gate=%s"
|
|
1322
|
+
% (rep["vmaf"]["score"], v1080, gap, rs,
|
|
1323
|
+
rep["gate"]["status"]))
|
|
1324
|
+
|
|
1325
|
+
def c_upscale_fail():
|
|
1326
|
+
# 2560x1440 成片 = 上采样,不是合法交付 -> fail(不是 skipped/pass)
|
|
1327
|
+
p = os.path.join(tmp, "up.mp4")
|
|
1328
|
+
cmd = [_ffmpeg(), "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgb24",
|
|
1329
|
+
"-s", "%dx%d" % (W, H), "-r", "%g" % fps, "-i", "-",
|
|
1330
|
+
"-vf", "scale=2560:1440:flags=lanczos",
|
|
1331
|
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "30",
|
|
1332
|
+
"-pix_fmt", "yuv420p", p]
|
|
1333
|
+
data = b"".join(f.tobytes() for f in refs)
|
|
1334
|
+
q = subprocess.run(cmd, input=data, capture_output=True)
|
|
1335
|
+
if q.returncode != 0:
|
|
1336
|
+
return False, "编码失败:%s" % _tail(q.stderr.decode("utf-8", "replace"), 200)
|
|
1337
|
+
rep = report(p, tl_up, mod, script, mode="full", log=None)
|
|
1338
|
+
msg = " ".join(rep["errors"])
|
|
1339
|
+
ok = (rep["gate"]["status"] == "fail" and not rep["judged"]
|
|
1340
|
+
and "上采样" in msg)
|
|
1341
|
+
return ok, rep["gate"]["detail"]
|
|
1342
|
+
|
|
1343
|
+
def c_declared_fail():
|
|
1344
|
+
# 1080p 成片 + timeline 声明 720p:规格被改过/拿错片 -> fail
|
|
1345
|
+
rep = report(film_ok, tl720, mod, script, mode="full", log=None)
|
|
1346
|
+
msg = " ".join(rep["errors"])
|
|
1347
|
+
ok = (rep["gate"]["status"] == "fail" and not rep["judged"]
|
|
1348
|
+
and "不一致" in msg)
|
|
1349
|
+
return ok, rep["gate"]["detail"]
|
|
1350
|
+
|
|
1351
|
+
def c_aspect_fail():
|
|
1352
|
+
# 1280x960 = 4:3,不是 16:9:不能按"缩放交付"放行
|
|
1353
|
+
p = os.path.join(tmp, "4x3.mp4")
|
|
1354
|
+
cmd = [_ffmpeg(), "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgb24",
|
|
1355
|
+
"-s", "%dx%d" % (W, H), "-r", "%g" % fps, "-i", "-",
|
|
1356
|
+
"-vf", "scale=1280:960:flags=lanczos",
|
|
1357
|
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "30",
|
|
1358
|
+
"-pix_fmt", "yuv420p", p]
|
|
1359
|
+
data = b"".join(f.tobytes() for f in refs)
|
|
1360
|
+
q = subprocess.run(cmd, input=data, capture_output=True)
|
|
1361
|
+
if q.returncode != 0:
|
|
1362
|
+
return False, "编码失败:%s" % _tail(q.stderr.decode("utf-8", "replace"), 200)
|
|
1363
|
+
rep = report(p, tl_legacy, mod, script, mode="full", log=None)
|
|
1364
|
+
msg = " ".join(rep["errors"])
|
|
1365
|
+
ok = (rep["gate"]["status"] == "fail" and "16:9" in msg)
|
|
1366
|
+
return ok, rep["gate"]["detail"]
|
|
1367
|
+
|
|
1368
|
+
check("正常片 VMAF 高、不误判 fail", c_pass)
|
|
1369
|
+
check("劣化片 VMAF 低、判 fail", c_bad)
|
|
1370
|
+
check("好片/劣化片分数区分度 > 15", c_discriminate)
|
|
1371
|
+
check("缺 scenes -> skipped(不是 pass)", c_skip_no_scenes)
|
|
1372
|
+
check("缺 timeline -> skipped(不是 pass)", c_skip_no_timeline)
|
|
1373
|
+
check("sample 模式帧数按步长缩减", c_sample)
|
|
1374
|
+
check("limit_seconds 生效并记 warning", c_limit)
|
|
1375
|
+
check("720p 下采样交付 -> pass 且与 1080p 差 < 3 分", c_downscale_pass)
|
|
1376
|
+
check("上采样成片 -> fail(不是合法交付)", c_upscale_fail)
|
|
1377
|
+
check("成片尺寸与 timeline.video 声明不符 -> fail", c_declared_fail)
|
|
1378
|
+
check("非 16:9 成片 -> fail", c_aspect_fail)
|
|
1379
|
+
finally:
|
|
1380
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
1381
|
+
|
|
1382
|
+
npass = sum(1 for _n, ok, _d in results if ok)
|
|
1383
|
+
print("== 结果:%d/%d PASS" % (npass, len(results)))
|
|
1384
|
+
return 0 if npass == len(results) else 1
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
# ---------------------------------------------------------------- CLI
|
|
1388
|
+
def main(argv=None) -> int:
|
|
1389
|
+
ap = argparse.ArgumentParser(
|
|
1390
|
+
prog="qavmaf.py",
|
|
1391
|
+
description="全片 VMAF/SSIM 画质门禁:参考帧由场景函数实时渲染(v0.8.0)")
|
|
1392
|
+
ap.add_argument("--film", default=None, help="成片路径(mp4)")
|
|
1393
|
+
ap.add_argument("--timeline", default=None, help="work/timeline.json")
|
|
1394
|
+
ap.add_argument("--scenes", default=None, help="scenes.py(含 draw_frame)")
|
|
1395
|
+
ap.add_argument("--script", default=None, help="film.json(与 frames 阶段同一份)")
|
|
1396
|
+
ap.add_argument("--mode", choices=("full", "sample"), default="full",
|
|
1397
|
+
help="full=逐帧(默认);sample=抽样(默认每 5 帧取 1 帧)")
|
|
1398
|
+
ap.add_argument("--sample-step", type=int, default=None, help="抽样步长(默认 5)")
|
|
1399
|
+
ap.add_argument("--limit-seconds", type=float, default=None,
|
|
1400
|
+
help="只比较前 N 秒(自测/冒烟用;会记 warning)")
|
|
1401
|
+
ap.add_argument("--vmaf-pass", type=float, default=VMAF_PASS, help="VMAF 失败阈值")
|
|
1402
|
+
ap.add_argument("--vmaf-warn", type=float, default=VMAF_WARN, help="VMAF 建议阈值")
|
|
1403
|
+
ap.add_argument("--ssim-pass", type=float, default=SSIM_PASS, help="SSIM 诊断阈值")
|
|
1404
|
+
ap.add_argument("--ssim-warn", type=float, default=SSIM_WARN, help="SSIM 建议阈值")
|
|
1405
|
+
ap.add_argument("--threads", type=int, default=DEFAULT_THREADS,
|
|
1406
|
+
help="libvmaf 线程数(默认 min(8, CPU);0=由 libvmaf 自决)")
|
|
1407
|
+
ap.add_argument("--out", default=None, help="报告 JSON 输出路径(缺省不写)")
|
|
1408
|
+
ap.add_argument("--json", action="store_true", help="stdout 只输出报告 JSON")
|
|
1409
|
+
ap.add_argument("--selftest", action="store_true", help="跑内置自检(不联网)")
|
|
1410
|
+
a = ap.parse_args(argv)
|
|
1411
|
+
_setup_stdio()
|
|
1412
|
+
if a.selftest:
|
|
1413
|
+
return selftest()
|
|
1414
|
+
missing = [k for k in ("film", "timeline", "scenes", "script")
|
|
1415
|
+
if not getattr(a, k)]
|
|
1416
|
+
if missing:
|
|
1417
|
+
ap.error("缺少参数:--%s(或用 --selftest)" % " / --".join(missing))
|
|
1418
|
+
logf = (lambda msg: print(msg, file=sys.stderr)) if a.json else print
|
|
1419
|
+
try:
|
|
1420
|
+
rep = report(a.film, a.timeline, a.scenes, a.script, mode=a.mode,
|
|
1421
|
+
out_json=a.out, vmaf_pass=a.vmaf_pass, vmaf_warn=a.vmaf_warn,
|
|
1422
|
+
ssim_pass=a.ssim_pass, ssim_warn=a.ssim_warn,
|
|
1423
|
+
limit_seconds=a.limit_seconds, sample_step=a.sample_step,
|
|
1424
|
+
threads=a.threads, log=logf)
|
|
1425
|
+
except Exception as e: # noqa: BLE001
|
|
1426
|
+
if a.json:
|
|
1427
|
+
print(json.dumps({"judged": False,
|
|
1428
|
+
"gate": {"status": "skipped",
|
|
1429
|
+
"detail": "qavmaf 内部错误:%s" % e}},
|
|
1430
|
+
ensure_ascii=False))
|
|
1431
|
+
print("[ERROR] qavmaf 内部错误:%s" % e, file=sys.stderr)
|
|
1432
|
+
return 2
|
|
1433
|
+
if a.json:
|
|
1434
|
+
print(json.dumps(rep, ensure_ascii=False, indent=1, allow_nan=False))
|
|
1435
|
+
else:
|
|
1436
|
+
_print_summary(rep, a.film, a.out)
|
|
1437
|
+
st = str((rep.get("gate") or {}).get("status") or "skipped")
|
|
1438
|
+
return 0 if st == "pass" else (1 if st == "fail" else 2)
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
if __name__ == "__main__":
|
|
1442
|
+
sys.exit(main())
|