@dsh-gis/dsh-video-studio-skill 0.6.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +951 -0
- package/README.md +89 -21
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +23 -7
- package/package.json +7 -3
- package/schemas/film.schema.json +235 -4
- package/skills/video-studio/SKILL.md +299 -27
- package/skills/video-studio/assets/film.json +7 -14
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/scenes.py +115 -10
- package/skills/video-studio/assets/web/engine.js +81 -3
- package/skills/video-studio/assets/web/film.js +249 -101
- package/skills/video-studio/assets/web/index.html +9 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +159 -0
- package/skills/video-studio/references/audio.md +67 -5
- package/skills/video-studio/references/change-impact.md +2 -2
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/style-packs.md +7 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +16 -1
- package/skills/video-studio/scripts/filmcheck.py +202 -12
- package/skills/video-studio/scripts/filmkit.py +792 -56
- package/skills/video-studio/scripts/gates.py +161 -14
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +154 -23
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +1134 -78
- package/skills/video-studio/scripts/scorekit.py +305 -76
- package/skills/video-studio/scripts/selfcheck.py +576 -2
- package/skills/video-studio/scripts/sfxkit.py +262 -44
- package/skills/video-studio/scripts/stagewatch.py +159 -36
- package/skills/video-studio/scripts/stylekit.py +137 -18
- package/skills/video-studio/scripts/voicekit.py +23 -7
- package/skills/video-studio/scripts/webengine.py +1186 -85
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +310 -59
- package/tools/estimate.py +251 -61
- package/tools/golden_frames.py +664 -0
- package/tools/selftest_noscipy.py +70 -0
|
@@ -54,13 +54,41 @@ def _sha1_file(path):
|
|
|
54
54
|
return "missing"
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
WEB_ASSET_EXT = (".js", ".mjs", ".html")
|
|
58
|
+
# web 引擎的画面逻辑在页面脚本里,不在 scenes.py 里。这几个后缀按文件名排序后逐个取
|
|
59
|
+
# sha1,再合并成一个指纹;不含 .css(样式改动不影响"画面语义",也确实常见于调色试错)。
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def web_asset_fingerprint(entry):
|
|
63
|
+
"""入口页面同目录下 .js/.mjs/.html 的内容指纹(按文件名排序后拼接)。
|
|
64
|
+
|
|
65
|
+
返回 (combined, files):combined 是 16 位 hex(目录读不了返回 "missing");
|
|
66
|
+
files 是参与计算的 [文件名],只用于报告/排错,不进指纹(避免绝对路径进 hash)。
|
|
67
|
+
"""
|
|
68
|
+
if not entry:
|
|
69
|
+
return "missing", []
|
|
70
|
+
d = os.path.dirname(os.path.abspath(str(entry)))
|
|
71
|
+
try:
|
|
72
|
+
names = sorted(n for n in os.listdir(d) if n.lower().endswith(WEB_ASSET_EXT))
|
|
73
|
+
except OSError:
|
|
74
|
+
return "missing", []
|
|
75
|
+
if not names:
|
|
76
|
+
return "missing", []
|
|
77
|
+
rows = []
|
|
78
|
+
for n in names:
|
|
79
|
+
rows.append("%s:%s" % (n, _sha1_file(os.path.join(d, n))))
|
|
80
|
+
return _sha1_text("\n".join(rows)), names
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def content_hash(script=None, scenes_path="", script_path="", engine="pil", web_entry=""):
|
|
84
|
+
"""本次渲染的"内容身份":剧本 + 场景代码 + 引擎(web 时再加页面脚本)。
|
|
59
85
|
|
|
60
86
|
只取与"这一版画面/声音"相关的量:
|
|
61
87
|
* 剧本:去掉渲染期注入的 _path/_dir/_style 等私有键后规范化 json;
|
|
62
88
|
* scenes.py:文件内容(画面逻辑变了,旧签核就得失效);
|
|
63
|
-
* engine:pil 与 web
|
|
89
|
+
* engine:pil 与 web 是两条渲染路径,产物不可互换;
|
|
90
|
+
* web_entry:engine=web 时,入口页同目录的 .js/.mjs/.html 一起算。
|
|
91
|
+
**pil 路线不参与**——否则老 PIL 项目每次升级都会被判"内容已变",签核无故失效。
|
|
64
92
|
"""
|
|
65
93
|
body = {}
|
|
66
94
|
if isinstance(script, dict):
|
|
@@ -68,27 +96,40 @@ def content_hash(script=None, scenes_path="", script_path="", engine="pil"):
|
|
|
68
96
|
if str(k).startswith("_"):
|
|
69
97
|
continue
|
|
70
98
|
body[k] = v
|
|
99
|
+
eng = str(engine or "pil")
|
|
71
100
|
payload = {
|
|
72
101
|
"script": _sha1_text(json.dumps(body, sort_keys=True, ensure_ascii=False, default=str)),
|
|
73
102
|
"script_file": _sha1_file(script_path) if script_path else "",
|
|
74
103
|
"scenes": _sha1_file(scenes_path) if scenes_path else "",
|
|
75
|
-
"engine":
|
|
104
|
+
"engine": eng,
|
|
76
105
|
}
|
|
106
|
+
if eng == "web":
|
|
107
|
+
assets, _names = web_asset_fingerprint(web_entry)
|
|
108
|
+
payload["web_assets"] = assets
|
|
77
109
|
return _sha1_text(json.dumps(payload, sort_keys=True))
|
|
78
110
|
|
|
79
111
|
|
|
80
112
|
class GateLedger:
|
|
81
113
|
"""一次运行内的门禁账本(多 stage 共用;--stage all 时是同一个对象)。"""
|
|
82
114
|
|
|
83
|
-
def __init__(self, work, script=None, scenes_path="", script_path="", engine="pil"
|
|
115
|
+
def __init__(self, work, script=None, scenes_path="", script_path="", engine="pil",
|
|
116
|
+
web_entry="", run_id=""):
|
|
84
117
|
self.work = str(work or "work")
|
|
85
118
|
self.items = []
|
|
86
119
|
self.t0 = time.time()
|
|
120
|
+
# v0.8:run_id 是"这一版"的标签(--run-id)。没给就按时间戳生成,
|
|
121
|
+
# 保证每次运行在 work/runs/<runId>/ 下都有独立记录,而不是互相覆盖。
|
|
122
|
+
self.run_id = str(run_id or "").strip() or time.strftime("r%Y%m%d-%H%M%S")
|
|
87
123
|
self.script = script or {}
|
|
88
124
|
self.scenes_path = str(scenes_path or "")
|
|
89
125
|
self.script_path = str(script_path or "")
|
|
90
126
|
self.engine = str(engine or "pil")
|
|
91
|
-
self.
|
|
127
|
+
self.web_entry = str(web_entry or "")
|
|
128
|
+
self.web_assets = []
|
|
129
|
+
self.content_hash = content_hash(script, self.scenes_path, self.script_path,
|
|
130
|
+
self.engine, web_entry=self.web_entry)
|
|
131
|
+
if self.engine == "web":
|
|
132
|
+
_fp, self.web_assets = web_asset_fingerprint(self.web_entry)
|
|
92
133
|
self.bypassed = [] # --no-gate 绕过的记录(必须留痕)
|
|
93
134
|
|
|
94
135
|
# ---- 记账 ----
|
|
@@ -142,9 +183,30 @@ class GateLedger:
|
|
|
142
183
|
|
|
143
184
|
# ---- 落盘 ----
|
|
144
185
|
def to_dict(self):
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
186
|
+
d = dict(schema=SCHEMA, at=self.t0, contentHash=self.content_hash,
|
|
187
|
+
runId=self.run_id,
|
|
188
|
+
engine=self.engine, counts=self.counts(),
|
|
189
|
+
items=list(self.items), bypassed=list(self.bypassed),
|
|
190
|
+
inputs=self.input_fingerprints())
|
|
191
|
+
if self.engine == "web":
|
|
192
|
+
# 审计用:让用户/CI 能看出这个指纹到底盖了哪些页面脚本(不进 hash,绝对路径不外泄)
|
|
193
|
+
d["webAssets"] = list(self.web_assets)
|
|
194
|
+
return d
|
|
195
|
+
|
|
196
|
+
def input_fingerprints(self):
|
|
197
|
+
"""输入指纹快照(v0.8):剧本 / scenes / 词级 / 本脚本。
|
|
198
|
+
|
|
199
|
+
为什么要单独记:contentHash 是"一个 16 位 hex",能判"变了没",但**说不出
|
|
200
|
+
变了哪个**。批量出片时最常见的问题是"这条用的还是上一版的词级时间戳",
|
|
201
|
+
有个逐项指纹才能在事后对账(也让 QT 报告能标出"本次跑的是哪一版输入")。
|
|
202
|
+
"""
|
|
203
|
+
return {
|
|
204
|
+
"script": _sha1_file(self.script_path) if self.script_path else "missing",
|
|
205
|
+
"scenes": _sha1_file(self.scenes_path) if self.scenes_path else "missing",
|
|
206
|
+
"words": _sha1_file(os.path.join(self.work, "words.json")),
|
|
207
|
+
"gates_py": _sha1_file(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
208
|
+
"filmkit.py")),
|
|
209
|
+
}
|
|
148
210
|
|
|
149
211
|
def write_json(self):
|
|
150
212
|
"""work/gates.json —— 给程序读(QC-REPORT.md 只是它的渲染层)。"""
|
|
@@ -156,6 +218,37 @@ class GateLedger:
|
|
|
156
218
|
return p
|
|
157
219
|
|
|
158
220
|
|
|
221
|
+
def archive_run(work, ledger, artifacts=None, report_path=None):
|
|
222
|
+
"""把"这一版"的账本快照归档到 work/runs/<runId>/(v0.8)。
|
|
223
|
+
|
|
224
|
+
只归档**小文件**(gates.json / QC-REPORT.md / run.json)。视频/音频/分块体积大,
|
|
225
|
+
就地保留即可——这里要解决的是"跑了几版之后,谁都说不清上一版的门禁结论是什么",
|
|
226
|
+
不是备份媒体。返回 run 目录。
|
|
227
|
+
"""
|
|
228
|
+
d = os.path.join(str(work), "runs", str(ledger.run_id))
|
|
229
|
+
try:
|
|
230
|
+
os.makedirs(d, exist_ok=True)
|
|
231
|
+
except OSError as e: # noqa: BLE001
|
|
232
|
+
return "", "建目录失败:%s" % e
|
|
233
|
+
info = dict(runId=ledger.run_id, at=ledger.t0,
|
|
234
|
+
atText=time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
235
|
+
contentHash=ledger.content_hash, counts=ledger.counts(),
|
|
236
|
+
inputs=ledger.input_fingerprints(),
|
|
237
|
+
artifacts=[str(x) for x in (artifacts or [])])
|
|
238
|
+
_atomic_json(os.path.join(d, "run.json"), info)
|
|
239
|
+
for src, name in ((os.path.join(str(work), "gates.json"), "gates.json"),
|
|
240
|
+
(str(report_path or ""), "QC-REPORT.md")):
|
|
241
|
+
try:
|
|
242
|
+
if src and os.path.exists(src):
|
|
243
|
+
with open(src, "rb") as fh:
|
|
244
|
+
data = fh.read()
|
|
245
|
+
with open(os.path.join(d, name), "wb") as fh:
|
|
246
|
+
fh.write(data)
|
|
247
|
+
except OSError:
|
|
248
|
+
pass
|
|
249
|
+
return d, ""
|
|
250
|
+
|
|
251
|
+
|
|
159
252
|
# ---------------------------------------------------------------- 签核账本
|
|
160
253
|
def signoff_path(work):
|
|
161
254
|
return os.path.join(str(work), "signoff.json")
|
|
@@ -288,9 +381,22 @@ def render_report(work, out=None):
|
|
|
288
381
|
ch = str(g.get("contentHash") or "")
|
|
289
382
|
items = list(g.get("items") or [])
|
|
290
383
|
counts = {OK: 0, FAIL: 0, SKIP: 0}
|
|
384
|
+
# 坏条目必须先归到"不通过"里,不能让它从三种计数里漏出去:
|
|
385
|
+
# 以前 st 不是 pass/fail/skipped 时 counts 只加了个 count[x],而结论分支只看
|
|
386
|
+
# counts[FAIL] / counts[SKIP] —— 于是 "status": "warn"(或大小写写错、null)
|
|
387
|
+
# 的条目在矩阵里照样列出来,结论却写"所有门禁本次都执行且通过"。
|
|
388
|
+
# 这正是本模块开头要防的"看起来干净、其实没在查",所以未知状态一律按 fail 计。
|
|
389
|
+
bad_items = 0
|
|
291
390
|
for x in items:
|
|
292
|
-
|
|
293
|
-
|
|
391
|
+
if not isinstance(x, dict):
|
|
392
|
+
bad_items += 1
|
|
393
|
+
continue
|
|
394
|
+
st = x.get("status")
|
|
395
|
+
if st not in (OK, FAIL, SKIP):
|
|
396
|
+
bad_items += 1
|
|
397
|
+
counts[FAIL] += 1
|
|
398
|
+
continue
|
|
399
|
+
counts[st] += 1
|
|
294
400
|
state, stale = signoff_state(work, ch)
|
|
295
401
|
signed_n = sum(1 for v in state.values() if v["status"] == OK)
|
|
296
402
|
|
|
@@ -304,11 +410,20 @@ def render_report(work, out=None):
|
|
|
304
410
|
L.append("| 项 | 值 |")
|
|
305
411
|
L.append("|---|---|")
|
|
306
412
|
L.append("| 内容指纹 | `%s` |" % (ch or "未生成"))
|
|
413
|
+
L.append("| 运行编号 | `%s` |" % (g.get("runId") or "-"))
|
|
307
414
|
L.append("| 引擎 | `%s` |" % (g.get("engine") or "-"))
|
|
415
|
+
_inp = g.get("inputs") or {}
|
|
416
|
+
if _inp:
|
|
417
|
+
L.append("| 输入指纹 | script `%s` · scenes `%s` · words `%s` |"
|
|
418
|
+
% (_inp.get("script", "-"), _inp.get("scenes", "-"),
|
|
419
|
+
_inp.get("words", "-")))
|
|
308
420
|
L.append("| 生成时间 | %s |"
|
|
309
421
|
% time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(g.get("at") or time.time())))
|
|
310
422
|
L.append("| 门禁合计 | pass %d · fail %d · skipped %d |"
|
|
311
423
|
% (counts.get(OK, 0), counts.get(FAIL, 0), counts.get(SKIP, 0)))
|
|
424
|
+
if bad_items:
|
|
425
|
+
L.append("| 账本损坏条目 | %d 条(status 不是 pass/fail/skipped)——按 fail 计 |"
|
|
426
|
+
% bad_items)
|
|
312
427
|
L.append("| 人工签核 | %d/%d 有效 |" % (signed_n, len(GATE_ORDER)))
|
|
313
428
|
L.append("")
|
|
314
429
|
L.append("## 一、门禁矩阵")
|
|
@@ -317,6 +432,12 @@ def render_report(work, out=None):
|
|
|
317
432
|
L.append("| 门禁 | 判据 | 结果 | 实测 / 说明 |")
|
|
318
433
|
L.append("|---|---|---|---|")
|
|
319
434
|
for x in items:
|
|
435
|
+
if not isinstance(x, dict):
|
|
436
|
+
# 坏条目不能让整份报告炸掉(.get 会 AttributeError,_finish_gates 只
|
|
437
|
+
# 打一行 WARN,交付目录里就没有 QC-REPORT.md 了):照实列成一行。
|
|
438
|
+
L.append("| `?` | (账本条目损坏) | **fail** | %r |"
|
|
439
|
+
% (str(x)[:60].replace("|", "\\|"),))
|
|
440
|
+
continue
|
|
320
441
|
L.append("| `%s` | %s | **%s** | %s |"
|
|
321
442
|
% (x.get("id", "?"), x.get("title", ""), x.get("status", "?"),
|
|
322
443
|
(x.get("detail") or "-").replace("|", "\\|")))
|
|
@@ -334,7 +455,8 @@ def render_report(work, out=None):
|
|
|
334
455
|
(v.get("detail") or "-").replace("|", "\\|")))
|
|
335
456
|
if stale:
|
|
336
457
|
L.append("")
|
|
337
|
-
L.append("> 内容已变的签核:%s(改了剧本 / scenes.py /
|
|
458
|
+
L.append("> 内容已变的签核:%s(改了剧本 / scenes.py / 引擎 / web 页面脚本,"
|
|
459
|
+
"旧签核自动失效,需重新签)"
|
|
338
460
|
% "、".join("`%s`" % x for x in stale))
|
|
339
461
|
L.append("")
|
|
340
462
|
L.append("## 三、耗时台账(`work/cost.jsonl` 实测,非估算)")
|
|
@@ -366,8 +488,23 @@ def render_report(work, out=None):
|
|
|
366
488
|
nxt = "四"
|
|
367
489
|
L.append("## %s、结论(照抄,别自己发挥)" % nxt)
|
|
368
490
|
L.append("")
|
|
369
|
-
if
|
|
491
|
+
if not items:
|
|
492
|
+
# 没有门禁记录 ≠ 全部通过。以前这里落到 else 分支打印"**通过**",
|
|
493
|
+
# 于是 gates.json 被删 / 写坏 / 根本没跑 qa 时,QC-REPORT.md 会是一份
|
|
494
|
+
# 宣称通过的交付凭据——正是本模块开头要防的"看起来干净、其实没在查"。
|
|
495
|
+
if not os.path.exists(gj):
|
|
496
|
+
why = "没有 %s(本次没有跑过任何 stage,或 work/ 目录已丢)" % gj
|
|
497
|
+
else:
|
|
498
|
+
why = "%s 读不出来 / 里面 0 条门禁(JSON 损坏或并发写坏)" % gj
|
|
499
|
+
L.append("- **未判定**:%s。**这不等于通过**——先跑 --stage qa(或 --stage all)"
|
|
500
|
+
"产出真实门禁再交付。" % why)
|
|
501
|
+
elif counts.get(FAIL):
|
|
370
502
|
L.append("- **未通过**:fail %d 项,按上表逐条修,别宣称完成。" % counts.get(FAIL))
|
|
503
|
+
elif bad_items:
|
|
504
|
+
# 条目损坏 / 状态值不认识:既不能算"通过",也不该说成"查了没过"。
|
|
505
|
+
L.append("- **未判定**:账本里有 %d 条损坏的记录(不是合法 dict 或 status 不是 "
|
|
506
|
+
"pass/fail/skipped),无法判断这些门禁到底查没查、过没过。"
|
|
507
|
+
"**这不等于通过**——重跑 --stage qa 重新产出账本。" % bad_items)
|
|
371
508
|
elif counts.get(SKIP):
|
|
372
509
|
L.append("- **有条件通过**:skipped %d 项(明确没执行),交付时必须逐项说明原因。"
|
|
373
510
|
% counts.get(SKIP))
|
|
@@ -439,7 +576,17 @@ if __name__ == "__main__":
|
|
|
439
576
|
p = write_report(a.work, a.out or None)
|
|
440
577
|
print("[QC 报告]", p)
|
|
441
578
|
if a.json:
|
|
442
|
-
|
|
579
|
+
# 读磁盘上的账本,别现场 new 一个空的:GateLedger(work) 不传剧本时
|
|
580
|
+
# contentHash 是按空剧本算的,打出来的 JSON 跟 work/gates.json 对不上、
|
|
581
|
+
# items 永远是 [] —— 机读输出等于撒谎。
|
|
582
|
+
gp = os.path.join(str(a.work), "gates.json")
|
|
583
|
+
try:
|
|
584
|
+
with open(gp, encoding="utf-8") as fh:
|
|
585
|
+
print(json.dumps(json.load(fh), ensure_ascii=False, indent=1))
|
|
586
|
+
except (OSError, json.JSONDecodeError) as err:
|
|
587
|
+
print(json.dumps(dict(ok=False, work=str(a.work),
|
|
588
|
+
error="没有可读的 %s:%s" % (gp, err)),
|
|
589
|
+
ensure_ascii=False, indent=1))
|
|
443
590
|
sys.exit(0)
|
|
444
591
|
except (ValueError, OSError) as e:
|
|
445
592
|
print("[ERROR] %s" % e, file=sys.stderr)
|