@dsh-gis/dsh-video-studio-skill 0.4.0 → 0.6.0
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/README.md +62 -12
- package/docs/styles/demo-results.json +76 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/package.json +11 -3
- package/schemas/film.schema.json +528 -0
- package/skills/video-studio/SKILL.md +166 -14
- package/skills/video-studio/assets/film.json +84 -23
- package/skills/video-studio/assets/scenes.py +892 -34
- package/skills/video-studio/references/audio.md +29 -1
- package/skills/video-studio/references/change-impact.md +54 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/style-packs.md +178 -0
- package/skills/video-studio/requirements.txt +11 -0
- package/skills/video-studio/scripts/filmcheck.py +656 -0
- package/skills/video-studio/scripts/filmkit.py +682 -58
- package/skills/video-studio/scripts/gates.py +446 -0
- package/skills/video-studio/scripts/render.py +562 -34
- package/skills/video-studio/scripts/scorekit.py +1642 -25
- package/skills/video-studio/scripts/selfcheck.py +346 -0
- package/skills/video-studio/scripts/stagewatch.py +295 -0
- package/skills/video-studio/scripts/stylekit.py +924 -0
- package/skills/video-studio/scripts/voicekit.py +872 -0
- package/tools/ci_check.py +339 -0
- package/tools/estimate.py +578 -0
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
22 (v0.3)webengine 可用时:页面契约齐全(window.renderFrame/getCues/ready)
|
|
29
29
|
23 (v0.3)qa_frames 可用时:能检出注入的单帧闪烁
|
|
30
30
|
24 (v0.4.0)配乐来源:music.mode=off 不读上一轮 work/music.wav(模式判定回归)
|
|
31
|
+
25 (v0.5.0)风格层:8 套主题全部过对比度门禁,8 类题材能分出 >=6 种风格
|
|
32
|
+
26 (v0.5.0)legacy 兼容:没写 style 字段必须精确落回 Dm/120/seed7/单声线
|
|
33
|
+
27 (v0.5.0)内容指纹:同剧本稳定、改标点不换风格、换题材才换
|
|
34
|
+
28 (v0.5.0)声线计划:4 句样片 >=2 把声音,legacy 仍是单声线
|
|
35
|
+
29 (v0.5.0)字体族与主题字幕:换族换缓存键,浅色主题不画黑背板
|
|
31
36
|
"""
|
|
32
37
|
import argparse
|
|
33
38
|
import io
|
|
@@ -514,6 +519,339 @@ def t_qa_frames():
|
|
|
514
519
|
return "5 个函数齐全"
|
|
515
520
|
|
|
516
521
|
|
|
522
|
+
|
|
523
|
+
def t_style_kits():
|
|
524
|
+
"""25 风格层:8 套主题过对比度门禁 + 自动选风格能把 8 类题材分开。"""
|
|
525
|
+
import stylekit as ST
|
|
526
|
+
bad = ST.lint_all()
|
|
527
|
+
assert not bad, "内置主题没过对比度门禁: %s" % bad
|
|
528
|
+
topics = {
|
|
529
|
+
"tech": "AI 算法 模型 数据 架构 开源 编程 大模型 代码 系统",
|
|
530
|
+
"report": "报告 复盘 年度 总结 研究 分析 调研 白皮书 趋势 发布",
|
|
531
|
+
"craft": "手工 自然 历史 传统 木 田野 匠 古 茶 手艺 山河",
|
|
532
|
+
"launch": "活动 上新 招募 挑战 年轻 潮流 种草 福利 开箱 限量",
|
|
533
|
+
"truth": "观点 为什么 本质 真相 极简 反思 边界 定义 取舍",
|
|
534
|
+
"story": "故事 记忆 城市 深夜 旅行 纪实 情绪 时代 变迁 命运",
|
|
535
|
+
"night": "深夜 学习 效率 专注 写作 整理 笔记 书 日常 房间",
|
|
536
|
+
"epic": "史诗 爆发 冲击 巅峰 决战 荣耀 震撼 传奇 极限 突破",
|
|
537
|
+
}
|
|
538
|
+
got = {}
|
|
539
|
+
for key, text in topics.items():
|
|
540
|
+
s = {"title": text, "style": "auto",
|
|
541
|
+
"lines": [{"id": "l01", "kind": "hook", "text": text},
|
|
542
|
+
{"id": "l02", "kind": "body", "text": text}]}
|
|
543
|
+
got[key] = ST.resolve_script_style(s)["style"]
|
|
544
|
+
assert len(set(got.values())) >= 6, "8 类题材只分出 %d 套风格: %s" % (len(set(got.values())), got)
|
|
545
|
+
return "%d 套主题 + %d 类题材 -> %d 种风格" % (len(ST.STYLE_IDS), len(topics), len(set(got.values())))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def t_style_legacy():
|
|
549
|
+
"""26 legacy 兼容:没写 style 字段必须精确落回 v0.4 的配乐/旁白参数。"""
|
|
550
|
+
import stylekit as ST
|
|
551
|
+
s = {"title": "旧项目", "lines": [{"id": "a", "kind": "body", "text": "x"}]}
|
|
552
|
+
p = ST.resolve_script_style(s)
|
|
553
|
+
assert p["legacy"] is True, "没有 style 字段却没走 legacy"
|
|
554
|
+
assert p["music"]["bpm"] == 120.0, "legacy BPM 应为 120,收到 %r" % (p["music"]["bpm"],)
|
|
555
|
+
assert p["music"]["key"] == "Dm", "legacy 调性应为 Dm,收到 %r" % (p["music"]["key"],)
|
|
556
|
+
assert p["music"]["seed_effective"] == 7, "legacy seed 应为 7(不做内容派生)"
|
|
557
|
+
assert p["music"]["seed_from_content"] is False
|
|
558
|
+
assert p["voice"]["map"] == {}, "legacy 不该有声线映射"
|
|
559
|
+
assert p["voice"]["policy"]["jitter"] == 0.0 and p["voice"]["policy"]["auto"] is False
|
|
560
|
+
# legacy 的节奏必须等于 render.py v0.4 的函数默认值,否则"没写 lead 的老剧本"会变节奏
|
|
561
|
+
assert (p["pacing"]["lead"], p["pacing"]["tail"], p["pacing"]["min_dur"]) == (0.55, 0.85, 3.0), p["pacing"]
|
|
562
|
+
# 显式 "legacy" 也必须是同一档
|
|
563
|
+
s2 = dict(s, style="legacy")
|
|
564
|
+
assert ST.resolve_script_style(s2)["legacy"] is True
|
|
565
|
+
# 显式风格则必须真的换掉三件套
|
|
566
|
+
s3 = dict(s, style="cinema")
|
|
567
|
+
p3 = ST.resolve_script_style(s3)
|
|
568
|
+
assert p3["legacy"] is False and p3["music"]["bpm"] in ST.STYLES["cinema"]["music"]["bpm_ladder"]
|
|
569
|
+
assert p3["music"]["key"] in ST.STYLES["cinema"]["music"]["keys"]
|
|
570
|
+
return "bpm=%g key=%s seed=%d / cinema: bpm=%g key=%s" % (
|
|
571
|
+
p["music"]["bpm"], p["music"]["key"], p["music"]["seed_effective"],
|
|
572
|
+
p3["music"]["bpm"], p3["music"]["key"])
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def t_style_determinism():
|
|
576
|
+
"""27 指纹确定性:同剧本同结果;改错别字不换风格;改题材才换。"""
|
|
577
|
+
import stylekit as ST
|
|
578
|
+
base = {"title": "AI 大模型的三年", "style": "auto",
|
|
579
|
+
"lines": [{"id": "l01", "kind": "hook", "text": "大模型改变了什么"},
|
|
580
|
+
{"id": "l02", "kind": "body", "text": "算法和数据的规模一起上去了"}]}
|
|
581
|
+
a = ST.resolve_script_style(base)
|
|
582
|
+
b = ST.resolve_script_style(json.loads(json.dumps(base)))
|
|
583
|
+
assert json.dumps(a, sort_keys=True, default=str) == json.dumps(b, sort_keys=True, default=str), \
|
|
584
|
+
"同一剧本两次解析结果不同(不确定性)"
|
|
585
|
+
typo = json.loads(json.dumps(base))
|
|
586
|
+
typo["lines"][1]["text"] += "。"
|
|
587
|
+
c = ST.resolve_script_style(typo)
|
|
588
|
+
assert c["style"] == a["style"], "改一个标点就换了风格: %s -> %s" % (a["style"], c["style"])
|
|
589
|
+
assert c["music"]["bpm"] == a["music"]["bpm"], "改一个标点就换了 BPM"
|
|
590
|
+
assert c["fingerprint"]["full"] != a["fingerprint"]["full"], "full 指纹没变(内容派生失效)"
|
|
591
|
+
other = {"title": "深夜学习指南", "style": "auto",
|
|
592
|
+
"lines": [{"id": "l01", "kind": "hook", "text": "为什么深夜反而能专注"},
|
|
593
|
+
{"id": "l02", "kind": "body", "text": "整理笔记,写作,阅读都更安静"}]}
|
|
594
|
+
d = ST.resolve_script_style(other)
|
|
595
|
+
assert d["style"] != a["style"], "换了一部片风格却相同: %s" % d["style"]
|
|
596
|
+
return "%s -> %s(同一剧本稳定),换片 -> %s" % (a["style"], c["style"], d["style"])
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def t_voice_plan():
|
|
600
|
+
"""28 声线计划:legacy 保持单声线;风格化时按 kind 换声线且确定性。"""
|
|
601
|
+
try:
|
|
602
|
+
import voicekit as VK
|
|
603
|
+
except Exception: # noqa: BLE001
|
|
604
|
+
return "skip"
|
|
605
|
+
import stylekit as ST
|
|
606
|
+
s = {"title": "AI 大模型", "style": "auto",
|
|
607
|
+
"lines": [{"id": "l01", "kind": "hook", "text": "大模型改变了什么"},
|
|
608
|
+
{"id": "l02", "kind": "body", "text": "算法和数据的规模一起上去了"},
|
|
609
|
+
{"id": "l03", "kind": "quote", "text": "他说:这不可能"},
|
|
610
|
+
{"id": "l04", "kind": "outro", "text": "它已经在我们身边了"}]}
|
|
611
|
+
plan = ST.resolve_script_style(s)
|
|
612
|
+
vp = VK.voice_plan(s, plan)
|
|
613
|
+
assert len(vp) == 4, "计划行数应为 4,收到 %d" % len(vp)
|
|
614
|
+
assert json.dumps(vp, sort_keys=True, default=str) == json.dumps(
|
|
615
|
+
VK.voice_plan(s, plan), sort_keys=True, default=str), "voice_plan 不确定"
|
|
616
|
+
personas = {r["persona"] for r in vp}
|
|
617
|
+
assert len(personas) >= 2, "4 句样片只用了一个声线: %s" % personas
|
|
618
|
+
tones = {r["tone"] for r in vp}
|
|
619
|
+
assert len(tones) >= 2, "语气档没有变化: %s" % tones
|
|
620
|
+
for r in vp:
|
|
621
|
+
for fld in ("voice", "rate", "pitch", "volume", "lead", "tail"):
|
|
622
|
+
assert r.get(fld) not in (None, ""), "计划行缺 %s: %r" % (fld, r.get("id"))
|
|
623
|
+
# legacy:单声线、body 语气、无抖动
|
|
624
|
+
leg = {"title": "旧项目", "lines": [{"id": "a", "kind": "body", "text": "x"}]}
|
|
625
|
+
lp = ST.resolve_script_style(leg)
|
|
626
|
+
lvp = VK.voice_plan(leg, lp)
|
|
627
|
+
assert len({r["voice"] for r in lvp}) == 1, "legacy 出现多声线"
|
|
628
|
+
assert all(r["tone"] == "body" for r in lvp), "legacy 不该换语气档"
|
|
629
|
+
return "%d 句 / %d 把声音 / %d 档语气" % (len(vp), len(personas), len(tones))
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def t_font_family():
|
|
633
|
+
"""29 字体族 + 字幕主题:换族必须换缓存键,主题字幕不能是黑底白字。
|
|
634
|
+
|
|
635
|
+
**别把"系统装了几族字体"当成产品缺陷**:CI 的 ubuntu 只装 fonts-noto-cjk,
|
|
636
|
+
四个族全回退到同一个 NotoSansCJK,宽度必然一样。v0.5.0 的第一版就在这里误报——
|
|
637
|
+
断言写成"宽度必须不同",于是 Linux CI 红、Windows 绿。
|
|
638
|
+
真正要保证的是**机制**:缓存按 family 分键(换族不会串味);
|
|
639
|
+
宽度差异只在"系统确实解析出不同字体文件"时才有意义,单族系统跳过并说明。
|
|
640
|
+
"""
|
|
641
|
+
fams = list(getattr(K, "FAMILY_IDS", ()))
|
|
642
|
+
assert fams, "filmkit 没有 FONT_FAMILIES(v0.5 字体族没生效)"
|
|
643
|
+
resolved = {}
|
|
644
|
+
for fam in fams:
|
|
645
|
+
f = K.font(40, "bold", fam)
|
|
646
|
+
assert f is not None, "字体族 %s 加载失败" % fam
|
|
647
|
+
assert f.size == 40, "字体族 %s 的字号被改写成 %s" % (fam, f.size)
|
|
648
|
+
resolved[fam] = str(getattr(f, "path", "") or "")
|
|
649
|
+
# (a) 机制保证:四个族必须是四条独立缓存,且键里带 family(否则 wrap 与绘制会用不同字体)
|
|
650
|
+
keys = [k for k in K._font_cache if len(k) == 3 and k[0] == 40 and k[1] == "bold"]
|
|
651
|
+
assert len({k[2] for k in keys}) == len(fams), \
|
|
652
|
+
"字体缓存键没带 family(换族会串味): %s" % (keys,)
|
|
653
|
+
# (b) 语义保证:仅当系统真解析出 >=2 个字体文件时,宽度才必须有差异
|
|
654
|
+
uniq_files = len(set(resolved.values()))
|
|
655
|
+
widths = {}
|
|
656
|
+
for fam in fams:
|
|
657
|
+
widths[fam] = round(K.text_w("中文测试ABC", K.font(40, "bold", fam)), 3)
|
|
658
|
+
uniq_w = len(set(widths.values()))
|
|
659
|
+
if uniq_files >= 2:
|
|
660
|
+
assert uniq_w >= 2, ("换族没换字形(%d 个字体文件却只有 %d 种宽度): %s"
|
|
661
|
+
% (uniq_files, uniq_w, widths))
|
|
662
|
+
detail = "%d 个字体族 / %d 个字体文件 / %d 种宽度" % (len(fams), uniq_files, uniq_w)
|
|
663
|
+
else:
|
|
664
|
+
# 单族回退(CI 的 fonts-noto-cjk,或极简 Linux 容器):只验机制,不验宽度
|
|
665
|
+
detail = "%d 个字体族 / 单族回退(%s)/ 只验缓存分键" % (
|
|
666
|
+
len(fams), os.path.basename(list(resolved.values())[0]) or "?")
|
|
667
|
+
# 缓存键必须区分 family(否则 wrap 与绘制会用不同字体,文字溢出安全区)
|
|
668
|
+
a = K.wrap_cn_cached("这是一句用来测试换行缓存键的话", 40, 300, "reg", "sans")
|
|
669
|
+
b = K.wrap_cn_cached("这是一句用来测试换行缓存键的话", 40, 300, "reg", "kai")
|
|
670
|
+
assert isinstance(a, list) and isinstance(b, list)
|
|
671
|
+
# 旧签名必须仍然可用(用户脚本里有大量 font(46,"reg"))
|
|
672
|
+
assert K.font(46, "reg") is not None
|
|
673
|
+
assert K.wrap_cn_cached("旧调用", 40, 500) is not None
|
|
674
|
+
# 字幕主题:浅色主题不该画黑背板
|
|
675
|
+
from PIL import Image
|
|
676
|
+
try:
|
|
677
|
+
import stylekit as ST
|
|
678
|
+
th = ST.THEMES["magazine"]
|
|
679
|
+
except Exception: # noqa: BLE001
|
|
680
|
+
th = None
|
|
681
|
+
if th is not None:
|
|
682
|
+
img = Image.new("RGB", (K.W, K.H), th["bg"])
|
|
683
|
+
K.draw_subtitle(img, "浅色主题字幕", theme=th)
|
|
684
|
+
px = img.getpixel((960, int((K.SUB_BAND[1] + K.SUB_BAND[3]) / 2)))
|
|
685
|
+
assert sum(px) > 200, "浅色主题的字幕背板是深色(%s),说明 theme 没生效" % (px,)
|
|
686
|
+
img2 = Image.new("RGB", (K.W, K.H), (0, 0, 0))
|
|
687
|
+
K.draw_subtitle(img2, "旧调用", theme=None)
|
|
688
|
+
return detail
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def t_gates():
|
|
692
|
+
"""30 门禁账本 / 签核 / QC 报告:三档状态必须能区分"没查"和"通过"。
|
|
693
|
+
|
|
694
|
+
这一条防的是 v0.6.0 自己最容易犯的错——把 skipped 混进 pass。
|
|
695
|
+
用户看到的"质检通过"必须能区分"我查了 9 项"和"我只查了 3 项":
|
|
696
|
+
前者是真知情,后者是假安全感。顺带验签核绑内容指纹:改了剧本旧签核必须失效。
|
|
697
|
+
"""
|
|
698
|
+
import tempfile
|
|
699
|
+
import gates as GT
|
|
700
|
+
w = tempfile.mkdtemp(prefix="sc_gates_")
|
|
701
|
+
lg = GT.GateLedger(w, {"title": "t", "lines": [{"text": "x"}]}, engine="pil")
|
|
702
|
+
lg.ok("A", "通过项", "1")
|
|
703
|
+
lg.fail("B", "失败项", "2")
|
|
704
|
+
lg.skip("C", "没执行项", "3")
|
|
705
|
+
c = lg.counts()
|
|
706
|
+
assert c == {"pass": 1, "fail": 1, "skipped": 1}, c
|
|
707
|
+
# 三档语义:fail 一定失败;skipped 只在 --strict-gates 下失败
|
|
708
|
+
assert len(lg.exit_errors()) == 1, "fail 没被算成失败"
|
|
709
|
+
assert len(lg.exit_errors(strict=True)) == 2, "--strict-gates 没把 skipped 升级成失败"
|
|
710
|
+
lg.write_json()
|
|
711
|
+
assert os.path.exists(os.path.join(w, "gates.json")), "gates.json 没落盘"
|
|
712
|
+
# 签核:没签 = skipped;签了 = pass;内容指纹变了 = skipped(不是 pass!)
|
|
713
|
+
ch = lg.content_hash
|
|
714
|
+
st0, _ = GT.signoff_state(w, ch)
|
|
715
|
+
assert all(v["status"] == "skipped" for v in st0.values()), st0
|
|
716
|
+
GT.write_signoff(w, "stills", ch, note="看过 sheet")
|
|
717
|
+
st1, _ = GT.signoff_state(w, ch)
|
|
718
|
+
assert st1["stills"]["status"] == "pass", st1["stills"]
|
|
719
|
+
st2, stale = GT.signoff_state(w, "别的指纹")
|
|
720
|
+
assert st2["stills"]["status"] == "skipped" and "stills" in stale, (st2, stale)
|
|
721
|
+
# 内容指纹:改剧本 / 换引擎必须变,渲染期私有键(_path/_style)不许影响
|
|
722
|
+
h1 = GT.content_hash({"title": "a", "lines": []}, engine="pil")
|
|
723
|
+
h2 = GT.content_hash({"_path": "/x", "title": "a", "lines": []}, engine="pil")
|
|
724
|
+
h3 = GT.content_hash({"title": "a", "lines": []}, engine="web")
|
|
725
|
+
assert h1 == h2, "渲染期私有键影响了内容指纹(会导致假失效)"
|
|
726
|
+
assert h1 != h3, "换引擎了内容指纹却没变(旧签核会错误地继续有效)"
|
|
727
|
+
# QC 报告:三档都要出现在矩阵里,且 skipped 不能被渲染成"通过"
|
|
728
|
+
GT.append_cost(w, "frames", 1.5, frames=30)
|
|
729
|
+
p = GT.write_report(w, os.path.join(w, "out", "QC-REPORT.md"))
|
|
730
|
+
txt = open(p, encoding="utf-8").read()
|
|
731
|
+
for must in ("门禁矩阵", "人工签核", "耗时台账", "**skipped**", "**fail**"):
|
|
732
|
+
assert must in txt, "报告里缺 %r" % must
|
|
733
|
+
assert "未通过" in txt and "通过" in txt, "报告结论段没区分通过/未通过"
|
|
734
|
+
# 原子写:不留 .tmp 尾巴
|
|
735
|
+
assert not [f for f in os.listdir(w) if f.endswith(".tmp")], os.listdir(w)
|
|
736
|
+
return "三档 1/1/1 + 签核 4 道闸 + 指纹失效 + 报告矩阵"
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def t_stagewatch():
|
|
740
|
+
"""31 阶段新鲜度:改上游 -> 下游必须报过时;首跑不刷噪声;产物丢失要报。
|
|
741
|
+
|
|
742
|
+
这一条防的是 v0.6.0 前的老问题:frames 有分块指纹,其余 stage 全是裸奔
|
|
743
|
+
(改 style 后 audio 仍读旧 music.wav、改 hold 后 frames 仍用旧 cues.json,都不报错)。
|
|
744
|
+
"""
|
|
745
|
+
import tempfile
|
|
746
|
+
import stagewatch as SW
|
|
747
|
+
w = tempfile.mkdtemp(prefix="sc_sw_")
|
|
748
|
+
assert all(not r["issues"] for r in SW.explain(w)), "空目录不该报过时"
|
|
749
|
+
|
|
750
|
+
def _wf(name, text):
|
|
751
|
+
p = os.path.join(w, name)
|
|
752
|
+
d = os.path.dirname(p)
|
|
753
|
+
if d:
|
|
754
|
+
os.makedirs(d, exist_ok=True)
|
|
755
|
+
open(p, "w", encoding="utf-8").write(text)
|
|
756
|
+
|
|
757
|
+
_wf("style.json", "s1")
|
|
758
|
+
_wf("voice_plan.json", "v1")
|
|
759
|
+
SW.snapshot(w, "style")
|
|
760
|
+
assert not [i for r in SW.explain(w, ["style"]) for i in r["issues"]], "刚跑完就报过时"
|
|
761
|
+
_wf("tts.json", "t1")
|
|
762
|
+
SW.snapshot(w, "tts")
|
|
763
|
+
# 改 tts 的上游(style.json)-> tts 必须报 input-changed
|
|
764
|
+
_wf("style.json", "s2")
|
|
765
|
+
issues = [i for r in SW.explain(w, ["tts"]) for i in r["issues"]]
|
|
766
|
+
assert any(i["kind"] == "input-changed" for i in issues), issues
|
|
767
|
+
assert any("render.py" in (i.get("rerun") or "") for i in issues), "过时了却没给重跑命令"
|
|
768
|
+
# 产物被删 -> 必须报 output-missing
|
|
769
|
+
os.remove(os.path.join(w, "tts.json"))
|
|
770
|
+
issues = [i for r in SW.explain(w, ["tts"]) for i in r["issues"]]
|
|
771
|
+
assert any(i["kind"] == "output-missing" for i in issues), issues
|
|
772
|
+
# 默认只告警;--strict-stale 才失败
|
|
773
|
+
errs, warns = SW.report(w, ["tts"], write=lambda lv, m: None)
|
|
774
|
+
assert not errs and warns, (errs, warns)
|
|
775
|
+
errs_strict, _ = SW.report(w, ["tts"], strict=True, write=lambda lv, m: None)
|
|
776
|
+
assert errs_strict, "--strict-stale 没有让过时变成失败"
|
|
777
|
+
# **源头输入**(剧本 / scenes.py)改了但没重跑 -> 必须报 source-changed。
|
|
778
|
+
# 这条是 v0.6.0 补的洞:早期只跟踪 stage 之间的产物,"改了台词没重跑 tts" 查不出来,
|
|
779
|
+
# 而那正是最需要报警的场景(旧 tts 配新剧本 = 配音字幕全错位)。
|
|
780
|
+
w2 = tempfile.mkdtemp(prefix="sc_sw2_")
|
|
781
|
+
film = os.path.join(w2, "film.json")
|
|
782
|
+
scenes = os.path.join(w2, "scenes.py")
|
|
783
|
+
open(film, "w", encoding="utf-8").write("{\"title\":\"a\"}")
|
|
784
|
+
open(scenes, "w", encoding="utf-8").write("# v1")
|
|
785
|
+
src = {"script": film, "scenes": scenes}
|
|
786
|
+
_wf2 = lambda n, t: open(os.path.join(w2, n), "w", encoding="utf-8").write(t)
|
|
787
|
+
_wf2("tts.json", "t1")
|
|
788
|
+
SW.snapshot(w2, "tts", src)
|
|
789
|
+
issues = [i for r in SW.explain(w2, ["tts"], src) for i in r["issues"]]
|
|
790
|
+
assert not issues, "刚跑完就报过时:%s" % issues
|
|
791
|
+
open(film, "w", encoding="utf-8").write("{\"title\":\"b\"}") # 改剧本
|
|
792
|
+
issues = [i for r in SW.explain(w2, ["tts"], src) for i in r["issues"]]
|
|
793
|
+
assert any(i["kind"] == "source-changed" for i in issues), (
|
|
794
|
+
"改了剧本却没报过时(这正是 v0.6.0 要堵的洞):%s" % issues)
|
|
795
|
+
assert any("film.json" in i["reason"] for i in issues), issues
|
|
796
|
+
# 坏 manifest 不许崩(用户手改坏一个 json 不该让整片跑不动)
|
|
797
|
+
open(os.path.join(w, "manifest.json"), "w", encoding="utf-8").write("{broken")
|
|
798
|
+
SW.explain(w)
|
|
799
|
+
return "改上游/改源头即报过时 + 产物丢失 + strict 升级 + 坏 manifest 不崩"
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def t_parallel_frames():
|
|
803
|
+
"""32 --jobs >1 的并行路径能真的跑(v0.6.0 修的 pre-existing bug)。
|
|
804
|
+
|
|
805
|
+
bug 形状:worker 从 _script_snapshot.json 读剧本,JSON 把主题色元组变成 list,
|
|
806
|
+
scenes._bg() 拿它当缓存键 -> TypeError: unhashable type: "list",
|
|
807
|
+
于是 SKILL 里教的 --stage frames --jobs 4 直接崩。
|
|
808
|
+
这里**不跑整片**(太慢),只验两条防线:① 主题色能过 JSON 往返当缓存键;
|
|
809
|
+
② worker 反序列化后会重解风格(_style 里的颜色回到 tuple)。
|
|
810
|
+
"""
|
|
811
|
+
import json as _json
|
|
812
|
+
import tempfile
|
|
813
|
+
import stylekit as ST
|
|
814
|
+
th = dict(ST.THEMES["tech-deep"])
|
|
815
|
+
# ① 防御层:scenes._hashable 把 list 冻结成 tuple
|
|
816
|
+
scenes = _load_scenes_mod()
|
|
817
|
+
if scenes is not None and hasattr(scenes, "_hashable"):
|
|
818
|
+
frozen = scenes._hashable(("baked", th["bg"], th["bg2"]))
|
|
819
|
+
hash(frozen) # 不能抛 unhashable
|
|
820
|
+
assert isinstance(frozen[1], tuple), "主题色没被冻结成 tuple:%r" % (frozen[1],)
|
|
821
|
+
# ② 根因层:JSON 往返后,_render_chunk_worker 里必须能拿到 tuple 的 bg
|
|
822
|
+
round_trip = _json.loads(_json.dumps({"bg": th["bg"]}, ensure_ascii=False))
|
|
823
|
+
assert isinstance(round_trip["bg"], list), "前提变了:JSON 居然保住了 tuple"
|
|
824
|
+
w = tempfile.mkdtemp(prefix="sc_par_")
|
|
825
|
+
snap = os.path.join(w, "snap.json")
|
|
826
|
+
script = {"title": "t", "style": "tech-deep", "lines": [{"text": "x"}]}
|
|
827
|
+
script["_style"] = ST.resolve_script_style(script)
|
|
828
|
+
with open(snap, "w", encoding="utf-8") as fh:
|
|
829
|
+
_json.dump(script, fh, ensure_ascii=False)
|
|
830
|
+
back = _json.load(open(snap, encoding="utf-8"))
|
|
831
|
+
assert isinstance(back["_style"]["theme"]["bg"], list), "前提变了"
|
|
832
|
+
# 重解风格(render.resolve_style 的纯函数部分):必须回到 tuple
|
|
833
|
+
again = ST.resolve_script_style(back)
|
|
834
|
+
assert isinstance(again["theme"]["bg"], tuple), (
|
|
835
|
+
"--jobs >1 时主题色仍是 list,_bg() 会 TypeError(这正是被修的 bug):%r"
|
|
836
|
+
% (again["theme"]["bg"],))
|
|
837
|
+
return "主题标量可哈希 + 反序列化后重解回 tuple"
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _load_scenes_mod():
|
|
841
|
+
"""加载 assets/scenes.py 模板(找不到就返回 None:CI 里可能只拷 scripts/)。"""
|
|
842
|
+
import importlib.util
|
|
843
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
844
|
+
cand = os.path.join(here, "..", "assets", "scenes.py")
|
|
845
|
+
if not os.path.exists(cand):
|
|
846
|
+
return None
|
|
847
|
+
try:
|
|
848
|
+
spec = importlib.util.spec_from_file_location("sc_assets_scenes", cand)
|
|
849
|
+
mod = importlib.util.module_from_spec(spec)
|
|
850
|
+
spec.loader.exec_module(mod)
|
|
851
|
+
return mod
|
|
852
|
+
except Exception: # noqa: BLE001
|
|
853
|
+
return None
|
|
854
|
+
|
|
517
855
|
CASES = [
|
|
518
856
|
("1 TTS 缓存校验文本", t_cache_key),
|
|
519
857
|
("2 sub_window 覆盖长句", t_sub_window),
|
|
@@ -539,6 +877,14 @@ CASES = [
|
|
|
539
877
|
("22 webengine 页面契约", t_webengine),
|
|
540
878
|
("23 qa_frames 函数齐全", t_qa_frames),
|
|
541
879
|
("24 配乐来源判定(off 不读旧配乐)", t_music_plan),
|
|
880
|
+
("25 风格层:8 套主题过对比度", t_style_kits),
|
|
881
|
+
("26 风格 legacy 兼容", t_style_legacy),
|
|
882
|
+
("27 内容指纹确定性", t_style_determinism),
|
|
883
|
+
("28 声线计划", t_voice_plan),
|
|
884
|
+
("29 字体族与主题字幕", t_font_family),
|
|
885
|
+
("30 门禁账本与签核", t_gates),
|
|
886
|
+
("31 阶段新鲜度守卫", t_stagewatch),
|
|
887
|
+
("32 并行渲染主题序列化", t_parallel_frames),
|
|
542
888
|
]
|
|
543
889
|
|
|
544
890
|
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""stagewatch.py — 阶段新鲜度(stale)守卫(v0.6.0)。
|
|
3
|
+
|
|
4
|
+
解决的是"改了一句台词,旧产物还在用,但不报错"这一类事故。
|
|
5
|
+
|
|
6
|
+
已经在 `_frames_plan_stamp` 里为一个点做过(改台词 -> 旧分块复用 -> 画面旁白对不上),
|
|
7
|
+
但其余 stage 全是裸奔:
|
|
8
|
+
* 改 style 后 `--stage audio` 仍读上一轮的 work/music.wav(配乐是旧的);
|
|
9
|
+
* 改 hold 后 `--stage frames` 仍用旧 cues.json(音效落位错);
|
|
10
|
+
* 单独跑 `--stage audio` 会无条件混入已存在的 work/sfx.wav。
|
|
11
|
+
|
|
12
|
+
机制:每个 stage 结束时往 `work/manifest.json` 记一行
|
|
13
|
+
{stage, at, inputs: {name: 指纹}, outputs: {name: 指纹}}
|
|
14
|
+
下次该 stage 开始前,重算**上游 inputs** 与**自己 outputs** 的指纹:
|
|
15
|
+
* 上游变了 -> 本 stage 的输入过时(会拿旧输入算出新产物);
|
|
16
|
+
* 自己的 outputs 变了 -> 下游全部过时。
|
|
17
|
+
两者都只**警告 + 给出最小重跑命令**,默认不阻断(可用 --strict-stale 升级为失败,
|
|
18
|
+
--allow-stale 显式放行并留痕)。这是"门禁可以保守,但不能静默"的同一条原则。
|
|
19
|
+
|
|
20
|
+
指纹策略:小文件用 sha1(准),大文件用 size+mtime(快)。
|
|
21
|
+
100 MB 的 audio.wav 每 stage 都算 sha1 会让"跑一次 qa"变慢一倍,而它的目的只是
|
|
22
|
+
"发现它被人动过",size+mtime 足够。
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
SCHEMA = 1
|
|
32
|
+
BIG_BYTES = 8 << 20 # > 8 MB 走 size+mtime 快路径
|
|
33
|
+
|
|
34
|
+
# 每个 stage 的输入与输出(相对 work/ 或绝对路径)。
|
|
35
|
+
# 顺序即依赖顺序:后面的 stage 用前面 stage 的产物。
|
|
36
|
+
# `sources` 是**源头输入**:剧本(film.json)与画面代码(scenes.py)。
|
|
37
|
+
# 早期版本只跟踪"stage 之间的产物",于是"改了一句台词但没重跑 tts"查不出任何过时——
|
|
38
|
+
# 而那正是最需要它报警的场景(旧 tts 配新剧本 = 配音和字幕全错位)。
|
|
39
|
+
STAGE_IO = {
|
|
40
|
+
"style": dict(sources=["script"], inputs=[], outputs=["style.json", "voice_plan.json"]),
|
|
41
|
+
"tts": dict(sources=["script"], inputs=["style.json"], outputs=["tts.json"]),
|
|
42
|
+
"layout": dict(sources=["script"],
|
|
43
|
+
inputs=["tts.json", "style.json", "voice_plan.json"],
|
|
44
|
+
outputs=["timeline.json", "sub.srt", "sub.ass"]),
|
|
45
|
+
"cues": dict(sources=["script", "scenes"], inputs=["timeline.json"],
|
|
46
|
+
outputs=["cues.json"]),
|
|
47
|
+
"score": dict(sources=["script"], inputs=["timeline.json", "style.json"],
|
|
48
|
+
outputs=["music.wav", "score.json"]),
|
|
49
|
+
"sfx": dict(sources=["script"], inputs=["cues.json"], outputs=["sfx.wav"]),
|
|
50
|
+
"audio": dict(sources=["script"],
|
|
51
|
+
inputs=["tts.json", "timeline.json", "music.wav", "sfx.wav"],
|
|
52
|
+
outputs=["audio.wav"]),
|
|
53
|
+
"frames": dict(sources=["script", "scenes"],
|
|
54
|
+
inputs=["timeline.json", "audio.wav", "cues.json"],
|
|
55
|
+
outputs=["silent.mp4"]),
|
|
56
|
+
"mux": dict(sources=["script"], inputs=["silent.mp4", "audio.wav"], outputs=[]),
|
|
57
|
+
"qa": dict(sources=["script", "scenes"], inputs=[],
|
|
58
|
+
outputs=["gates.json", "qa/sheet.png"]),
|
|
59
|
+
"stills": dict(sources=["script", "scenes"], inputs=["timeline.json", "style.json"],
|
|
60
|
+
outputs=[]),
|
|
61
|
+
}
|
|
62
|
+
STAGE_ORDER = tuple(STAGE_IO)
|
|
63
|
+
|
|
64
|
+
# 每个 stage 过时后的"最小重跑命令"提示(只提示本 stage + 必要的下游)
|
|
65
|
+
RERUN_HINT = {
|
|
66
|
+
"style": "python render.py --stage style tts layout cues score sfx audio frames mux qa",
|
|
67
|
+
"tts": "python render.py --stage tts layout cues sfx audio frames mux qa",
|
|
68
|
+
"layout": "python render.py --stage layout cues score sfx audio frames mux qa",
|
|
69
|
+
"cues": "python render.py --stage cues sfx audio frames mux qa",
|
|
70
|
+
"score": "python render.py --stage score audio mux qa",
|
|
71
|
+
"sfx": "python render.py --stage sfx audio mux qa",
|
|
72
|
+
"audio": "python render.py --stage audio mux qa",
|
|
73
|
+
"frames": "python render.py --stage frames mux qa (分块缓存会被指纹拒绝,需 rm -rf work/segments)",
|
|
74
|
+
"mux": "python render.py --stage mux qa",
|
|
75
|
+
"qa": "python render.py --stage qa",
|
|
76
|
+
"stills": "python render.py --stage stills",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fp(path, work):
|
|
81
|
+
"""文件指纹:缺失 -> 'missing';>8MB 用 size+mtime(快),否则 sha1(准)。"""
|
|
82
|
+
p = path if os.path.isabs(str(path)) else os.path.join(str(work), str(path))
|
|
83
|
+
try:
|
|
84
|
+
st = os.stat(p)
|
|
85
|
+
except OSError:
|
|
86
|
+
return "missing"
|
|
87
|
+
if st.st_size > BIG_BYTES:
|
|
88
|
+
return "sm:%d:%d" % (st.st_size, int(st.st_mtime))
|
|
89
|
+
try:
|
|
90
|
+
h = hashlib.sha1()
|
|
91
|
+
with open(p, "rb") as fh:
|
|
92
|
+
for blk in iter(lambda: fh.read(1 << 20), b""):
|
|
93
|
+
h.update(blk)
|
|
94
|
+
return "sha1:" + h.hexdigest()[:16]
|
|
95
|
+
except OSError:
|
|
96
|
+
return "missing"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def manifest_path(work):
|
|
100
|
+
return os.path.join(str(work), "manifest.json")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load(work):
|
|
104
|
+
p = manifest_path(work)
|
|
105
|
+
if not os.path.exists(p):
|
|
106
|
+
return {"schema": SCHEMA, "stages": {}}
|
|
107
|
+
try:
|
|
108
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
109
|
+
except (json.JSONDecodeError, OSError):
|
|
110
|
+
return {"schema": SCHEMA, "stages": {}, "broken": p}
|
|
111
|
+
if not isinstance(d, dict) or not isinstance(d.get("stages"), dict):
|
|
112
|
+
return {"schema": SCHEMA, "stages": {}, "broken": p}
|
|
113
|
+
return d
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def save(work, d):
|
|
117
|
+
os.makedirs(str(work), exist_ok=True)
|
|
118
|
+
p = manifest_path(work)
|
|
119
|
+
tmp = "%s.%d.tmp" % (p, os.getpid())
|
|
120
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
121
|
+
json.dump(d, fh, ensure_ascii=False, indent=1)
|
|
122
|
+
os.replace(tmp, p)
|
|
123
|
+
return p
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _src_fp(kind, sources):
|
|
127
|
+
"""源头输入指纹:script=剧本(film.json)、scenes=场景代码(scenes.py)。"""
|
|
128
|
+
p = (sources or {}).get(kind)
|
|
129
|
+
return _fp(p, ".") if p else "missing"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def snapshot(work, stage, sources=None):
|
|
133
|
+
"""记下某个 stage 的输入/输出指纹(stage 结束时调用)。"""
|
|
134
|
+
io = STAGE_IO.get(str(stage))
|
|
135
|
+
if io is None:
|
|
136
|
+
return None
|
|
137
|
+
d = load(work)
|
|
138
|
+
d.setdefault("stages", {})[str(stage)] = dict(
|
|
139
|
+
at=time.time(),
|
|
140
|
+
atText=time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
141
|
+
src={k: _src_fp(k, sources) for k in (io.get("sources") or [])},
|
|
142
|
+
inputs={k: _fp(k, work) for k in io["inputs"]},
|
|
143
|
+
outputs={k: _fp(k, work) for k in io["outputs"]},
|
|
144
|
+
)
|
|
145
|
+
save(work, d)
|
|
146
|
+
return d["stages"][str(stage)]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def check(work, stage, sources=None):
|
|
150
|
+
"""stage 开始前查新鲜度:返回问题列表(每项含 stage / reason / rerun)。
|
|
151
|
+
|
|
152
|
+
只报"这一跑真的会读到旧东西"的情况:
|
|
153
|
+
1) 本 stage 的上游输入与上次记录不同(上游被重跑过,或人手改过文件);
|
|
154
|
+
2) 本 stage 自己的产物比它的输入还旧(上次跑到一半被中断)。
|
|
155
|
+
另一个 stage 的产物变了但**不**是本周 stage 的输入时,不报(跟我们无关)。
|
|
156
|
+
"""
|
|
157
|
+
io = STAGE_IO.get(str(stage))
|
|
158
|
+
if io is None:
|
|
159
|
+
return []
|
|
160
|
+
d = load(work)
|
|
161
|
+
stages = d.get("stages") or {}
|
|
162
|
+
rec = stages.get(str(stage))
|
|
163
|
+
issues = []
|
|
164
|
+
if not isinstance(rec, dict):
|
|
165
|
+
# 从没跑过这个 stage:只有当"别的 stage 有记录"(说明是接手别人的 work 目录,
|
|
166
|
+
# 或这套产物是旧版本跑出来的)时才提示;全新目录第一次跑不刷噪声。
|
|
167
|
+
present = [k for k in io["inputs"]
|
|
168
|
+
if _fp(k, work) != "missing"]
|
|
169
|
+
# 只有"有声明产物"的阶段才谈得上"跑过但没记录"。stills / mux 的产物不在 work 里
|
|
170
|
+
# (mux 写 --out,stills 写 work/stills/),无从证明跑过,就别冤枉它们过时。
|
|
171
|
+
if stages and io["outputs"] and len(present) == len(io["inputs"]) and io["inputs"]:
|
|
172
|
+
issues.append(dict(stage=str(stage), kind="no-manifest",
|
|
173
|
+
reason="work 里有 %s 但 manifest.json 没有本阶段的记录"
|
|
174
|
+
"(旧版本跑过 / 手改过)" % "、".join(io["inputs"]),
|
|
175
|
+
rerun=RERUN_HINT.get(str(stage), "")))
|
|
176
|
+
return issues
|
|
177
|
+
# 源头先查:剧本 / scenes.py 改了但本 stage 没重跑 -> 产物一定过时
|
|
178
|
+
old_src = rec.get("src") or {}
|
|
179
|
+
for kind in (io.get("sources") or []):
|
|
180
|
+
now = _src_fp(kind, sources)
|
|
181
|
+
was = old_src.get(kind, "missing")
|
|
182
|
+
if now != was:
|
|
183
|
+
fname = {"script": "剧本 film.json", "scenes": "画面代码 scenes.py"}.get(kind, kind)
|
|
184
|
+
issues.append(dict(
|
|
185
|
+
stage=str(stage), kind="source-changed", file=kind,
|
|
186
|
+
reason="%s 在本阶段上次跑完之后改过(%s -> %s),但本阶段没有重跑;"
|
|
187
|
+
"它现在装的还是旧内容" % (fname, was, now),
|
|
188
|
+
rerun=RERUN_HINT.get(str(stage), ""),
|
|
189
|
+
))
|
|
190
|
+
old_inputs = rec.get("inputs") or {}
|
|
191
|
+
for name in io["inputs"]:
|
|
192
|
+
now = _fp(name, work)
|
|
193
|
+
was = old_inputs.get(name, "missing")
|
|
194
|
+
if now != was:
|
|
195
|
+
issues.append(dict(
|
|
196
|
+
stage=str(stage), kind="input-changed", file=name,
|
|
197
|
+
reason="输入 %s 在上次跑本阶段之后变了(%s -> %s),本次会拿**新输入**算,"
|
|
198
|
+
"但它下游的产物仍是旧的" % (name, was, now),
|
|
199
|
+
rerun=RERUN_HINT.get(str(stage), ""),
|
|
200
|
+
))
|
|
201
|
+
# 自己的产物丢了:上次跑到一半被中断 / 被删 / 被挪走(与"输入变了"是两件事,都要报)
|
|
202
|
+
out_fps = {k: _fp(k, work) for k in (rec.get("outputs") or {})}
|
|
203
|
+
missing_out = [k for k, v in out_fps.items() if v == "missing"]
|
|
204
|
+
if missing_out:
|
|
205
|
+
issues.append(dict(stage=str(stage), kind="output-missing",
|
|
206
|
+
reason="上次跑过本阶段,但产物 %s 不见了(被删 / 被挪 / 磁盘满)"
|
|
207
|
+
% "、".join(missing_out),
|
|
208
|
+
rerun=RERUN_HINT.get(str(stage), "")))
|
|
209
|
+
return issues
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def explain(work, stages=None, sources=None):
|
|
213
|
+
"""--explain-stale:把"现在哪些阶段是旧的、为什么、该跑什么"一次说清。"""
|
|
214
|
+
d = load(work)
|
|
215
|
+
rows = []
|
|
216
|
+
for st in (stages or STAGE_ORDER):
|
|
217
|
+
io = STAGE_IO.get(st)
|
|
218
|
+
if io is None:
|
|
219
|
+
continue
|
|
220
|
+
rec = (d.get("stages") or {}).get(st)
|
|
221
|
+
issues = check(work, st, sources)
|
|
222
|
+
rows.append(dict(stage=st, recorded=bool(rec),
|
|
223
|
+
at=(rec or {}).get("atText", ""),
|
|
224
|
+
issues=issues))
|
|
225
|
+
return rows
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def report(work, stages=None, strict=False, allow_stale=False, ledger=None, write=None,
|
|
229
|
+
sources=None):
|
|
230
|
+
"""查 + 打印 + 判定。返回 (errors, warnings);errors 在 strict 下非空。
|
|
231
|
+
|
|
232
|
+
ledger: 传 gates.GateLedger 时把结论记进账本(一条汇总 gate)。
|
|
233
|
+
write: 传 callable(str, str) 时按行输出(方便测试捕获)。
|
|
234
|
+
"""
|
|
235
|
+
out = write or (lambda level, msg: print(msg))
|
|
236
|
+
rows = explain(work, stages, sources)
|
|
237
|
+
problems = [(r["stage"], i) for r in rows for i in r["issues"]]
|
|
238
|
+
warns, errs = [], []
|
|
239
|
+
for st, it in problems:
|
|
240
|
+
msg = ("[stale] %s: %s" % (st, it["reason"]))
|
|
241
|
+
if allow_stale:
|
|
242
|
+
warns.append(msg + "(--allow-stale 已放行,本次结果可能掺旧数据)")
|
|
243
|
+
elif strict:
|
|
244
|
+
errs.append(msg)
|
|
245
|
+
else:
|
|
246
|
+
warns.append(msg)
|
|
247
|
+
if it.get("rerun"):
|
|
248
|
+
out("warn", " 最小重跑: %s" % it["rerun"])
|
|
249
|
+
if problems:
|
|
250
|
+
out("warn", "")
|
|
251
|
+
out("warn", " ↑ 共 %d 处过时。默认只告警;--strict-stale 让它失败,"
|
|
252
|
+
"--allow-stale 显式放行(会记进 QC 报告)。" % len(problems))
|
|
253
|
+
if ledger is not None:
|
|
254
|
+
try:
|
|
255
|
+
if not problems:
|
|
256
|
+
ledger.ok("stale", "阶段新鲜度", "上游输入与上次记录一致")
|
|
257
|
+
elif errs:
|
|
258
|
+
ledger.fail("stale", "阶段新鲜度", "%d 处过时(--strict-stale)" % len(problems))
|
|
259
|
+
else:
|
|
260
|
+
ledger.skip("stale", "阶段新鲜度",
|
|
261
|
+
"%d 处过时,已告警未阻断%s"
|
|
262
|
+
% (len(problems), "(--allow-stale)" if allow_stale else ""))
|
|
263
|
+
except Exception: # noqa: BLE001
|
|
264
|
+
pass
|
|
265
|
+
return errs, warns
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
import argparse
|
|
270
|
+
import sys
|
|
271
|
+
ap = argparse.ArgumentParser(description="阶段新鲜度检查:现在哪些产物是旧的")
|
|
272
|
+
ap.add_argument("--work", default="work")
|
|
273
|
+
ap.add_argument("--stage", default="", help="只看某个 stage(缺省 = 全部)")
|
|
274
|
+
ap.add_argument("--json", action="store_true")
|
|
275
|
+
a = ap.parse_args()
|
|
276
|
+
stages = [a.stage] if a.stage else None
|
|
277
|
+
rows = explain(a.work, stages)
|
|
278
|
+
if a.json:
|
|
279
|
+
print(json.dumps(rows, ensure_ascii=False, indent=1))
|
|
280
|
+
sys.exit(0)
|
|
281
|
+
bad = 0
|
|
282
|
+
for r in rows:
|
|
283
|
+
mark = "OK " if not r["issues"] else "!! "
|
|
284
|
+
if r["issues"]:
|
|
285
|
+
bad += 1
|
|
286
|
+
print("%s%-8s %s" % (mark, r["stage"], r["at"] or "(无记录)"))
|
|
287
|
+
for it in r["issues"]:
|
|
288
|
+
print(" -", it["reason"])
|
|
289
|
+
if it.get("rerun"):
|
|
290
|
+
print(" 最小重跑:", it["rerun"])
|
|
291
|
+
print()
|
|
292
|
+
if bad:
|
|
293
|
+
print("%d 个阶段过时。只想看某个:--stage <名字>;打印机读:--json" % bad)
|
|
294
|
+
sys.exit(1)
|
|
295
|
+
print("没有发现过时的阶段(按 manifest.json 记录判断)。")
|