@dsh-gis/dsh-video-studio-skill 0.4.0 → 0.7.2

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.
Files changed (38) hide show
  1. package/README.md +93 -18
  2. package/docs/styles/demo-results.json +76 -0
  3. package/docs/styles/sheet-8-styles.png +0 -0
  4. package/docs/styles/sheet-ai.png +0 -0
  5. package/docs/styles/sheet-craft.png +0 -0
  6. package/docs/styles/sheet-night.png +0 -0
  7. package/index.js +20 -7
  8. package/package.json +13 -3
  9. package/schemas/film.schema.json +599 -0
  10. package/skills/video-studio/SKILL.md +252 -28
  11. package/skills/video-studio/assets/film.json +78 -24
  12. package/skills/video-studio/assets/scenes.py +1005 -42
  13. package/skills/video-studio/assets/web/engine.js +18 -1
  14. package/skills/video-studio/assets/web/film.js +29 -4
  15. package/skills/video-studio/assets/web/index.html +5 -0
  16. package/skills/video-studio/references/audio-variation.md +158 -0
  17. package/skills/video-studio/references/audio.md +96 -6
  18. package/skills/video-studio/references/change-impact.md +54 -0
  19. package/skills/video-studio/references/quality-gates.md +1 -1
  20. package/skills/video-studio/references/style-packs.md +178 -0
  21. package/skills/video-studio/references/web-engine.md +1 -1
  22. package/skills/video-studio/requirements.txt +13 -1
  23. package/skills/video-studio/scripts/filmcheck.py +725 -0
  24. package/skills/video-studio/scripts/filmkit.py +953 -76
  25. package/skills/video-studio/scripts/gates.py +509 -0
  26. package/skills/video-studio/scripts/qa_frames.py +109 -19
  27. package/skills/video-studio/scripts/render.py +942 -45
  28. package/skills/video-studio/scripts/scorekit.py +1887 -27
  29. package/skills/video-studio/scripts/selfcheck.py +648 -2
  30. package/skills/video-studio/scripts/sfxkit.py +132 -30
  31. package/skills/video-studio/scripts/stagewatch.py +405 -0
  32. package/skills/video-studio/scripts/stylekit.py +1012 -0
  33. package/skills/video-studio/scripts/voicekit.py +880 -0
  34. package/skills/video-studio/scripts/webengine.py +68 -14
  35. package/tools/audio_similarity.py +279 -0
  36. package/tools/ci_check.py +450 -0
  37. package/tools/estimate.py +657 -0
  38. package/tools/selftest_noscipy.py +70 -0
@@ -0,0 +1,509 @@
1
+ # -*- coding: utf-8 -*-
2
+ """gates.py — 门禁账本 / 机读交付报告 / 人工签核(v0.6.0)。
3
+
4
+ 解决三件事(都是"文档承诺了、代码没做"):
5
+
6
+ 1) **门禁账本**:每个 stage 把判据结果记成一条 {id, title, status, detail},
7
+ status 只允许 pass / fail / skipped 三档。"没执行"必须独立成 skipped,
8
+ 不许混进 pass——否则"看起来干净、其实没在查"会一路带进交付。
9
+ 2) **机读交付报告** out/QC-REPORT.md:把账本渲染成 gate matrix + 实测值 +
10
+ 人工签核 + 耗时台账。在此之前,质检结论只活在 stdout 里,会话一断就没了。
11
+ 3) **人工签核** out/signoff.json:风格/母版/事实/交付四道"必须由人看"的闸。
12
+ 签核绑 contentHash(剧本 + scenes.py + 引擎),内容一变旧签核自动失效。
13
+
14
+ 设计约束:
15
+ * 纯标准库(hashlib/json/time/os),不引第三方。
16
+ * 账本与签核写 work/ 下,报告写 out/——渲染产物可整目录搬走。
17
+ * 不改变既有退出码语义:默认 skipped 只告警,--strict-gates 才升级为 error
18
+ (与 QA_REQUIRE_SYNC 同一套语义)。
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import time
26
+
27
+ SCHEMA = 1
28
+
29
+ # 人工签核的四道闸:id -> 人话标题
30
+ GATES = {
31
+ "style": "风格与声线已确认(看 work/style.json / 试听声线)",
32
+ "stills": "母版静帧已亲眼看过(work/qa/sheet.png)",
33
+ "facts": "上屏数字已核对出处(FACTS.md)",
34
+ "release": "成片已验收(音画 / 字幕 / 响度)",
35
+ }
36
+ GATE_ORDER = ("style", "stills", "facts", "release")
37
+
38
+ OK, FAIL, SKIP = "pass", "fail", "skipped"
39
+
40
+
41
+ def _sha1_text(s):
42
+ return hashlib.sha1(str(s).encode("utf-8")).hexdigest()[:16]
43
+
44
+
45
+ def _sha1_file(path):
46
+ """文件内容指纹;缺文件返回 'missing',不抛异常。"""
47
+ try:
48
+ h = hashlib.sha1()
49
+ with open(str(path), "rb") as fh:
50
+ for blk in iter(lambda: fh.read(1 << 20), b""):
51
+ h.update(blk)
52
+ return h.hexdigest()[:16]
53
+ except OSError:
54
+ return "missing"
55
+
56
+
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 时再加页面脚本)。
85
+
86
+ 只取与"这一版画面/声音"相关的量:
87
+ * 剧本:去掉渲染期注入的 _path/_dir/_style 等私有键后规范化 json;
88
+ * scenes.py:文件内容(画面逻辑变了,旧签核就得失效);
89
+ * engine:pil 与 web 是两条渲染路径,产物不可互换;
90
+ * web_entry:engine=web 时,入口页同目录的 .js/.mjs/.html 一起算。
91
+ **pil 路线不参与**——否则老 PIL 项目每次升级都会被判"内容已变",签核无故失效。
92
+ """
93
+ body = {}
94
+ if isinstance(script, dict):
95
+ for k, v in script.items():
96
+ if str(k).startswith("_"):
97
+ continue
98
+ body[k] = v
99
+ eng = str(engine or "pil")
100
+ payload = {
101
+ "script": _sha1_text(json.dumps(body, sort_keys=True, ensure_ascii=False, default=str)),
102
+ "script_file": _sha1_file(script_path) if script_path else "",
103
+ "scenes": _sha1_file(scenes_path) if scenes_path else "",
104
+ "engine": eng,
105
+ }
106
+ if eng == "web":
107
+ assets, _names = web_asset_fingerprint(web_entry)
108
+ payload["web_assets"] = assets
109
+ return _sha1_text(json.dumps(payload, sort_keys=True))
110
+
111
+
112
+ class GateLedger:
113
+ """一次运行内的门禁账本(多 stage 共用;--stage all 时是同一个对象)。"""
114
+
115
+ def __init__(self, work, script=None, scenes_path="", script_path="", engine="pil",
116
+ web_entry=""):
117
+ self.work = str(work or "work")
118
+ self.items = []
119
+ self.t0 = time.time()
120
+ self.script = script or {}
121
+ self.scenes_path = str(scenes_path or "")
122
+ self.script_path = str(script_path or "")
123
+ self.engine = str(engine or "pil")
124
+ self.web_entry = str(web_entry or "")
125
+ self.web_assets = []
126
+ self.content_hash = content_hash(script, self.scenes_path, self.script_path,
127
+ self.engine, web_entry=self.web_entry)
128
+ if self.engine == "web":
129
+ _fp, self.web_assets = web_asset_fingerprint(self.web_entry)
130
+ self.bypassed = [] # --no-gate 绕过的记录(必须留痕)
131
+
132
+ # ---- 记账 ----
133
+ def add(self, gid, title, status, detail="", at=None):
134
+ """记一条门禁。同一个 gid 重复记时**后写覆盖**(stage 可重跑)。"""
135
+ if status not in (OK, FAIL, SKIP):
136
+ raise ValueError("门禁状态只能是 pass/fail/skipped,收到 %r" % (status,))
137
+ item = dict(id=str(gid), title=str(title), status=status,
138
+ detail=str(detail or ""), at=float(at if at is not None else time.time()))
139
+ for i, old in enumerate(self.items):
140
+ if old["id"] == item["id"]:
141
+ self.items[i] = item
142
+ return item
143
+ self.items.append(item)
144
+ return item
145
+
146
+ def ok(self, gid, title, detail=""):
147
+ return self.add(gid, title, OK, detail)
148
+
149
+ def fail(self, gid, title, detail=""):
150
+ return self.add(gid, title, FAIL, detail)
151
+
152
+ def skip(self, gid, title, detail=""):
153
+ return self.add(gid, title, SKIP, detail)
154
+
155
+ def bypass(self, what, why=""):
156
+ self.bypassed.append(dict(what=str(what), why=str(why or ""), at=time.time()))
157
+
158
+ # ---- 判定 ----
159
+ def fails(self):
160
+ return [x for x in self.items if x["status"] == FAIL]
161
+
162
+ def skips(self):
163
+ return [x for x in self.items if x["status"] == SKIP]
164
+
165
+ def counts(self):
166
+ c = {OK: 0, FAIL: 0, SKIP: 0}
167
+ for x in self.items:
168
+ c[x["status"]] = c.get(x["status"], 0) + 1
169
+ return c
170
+
171
+ def exit_errors(self, strict=False):
172
+ """该让命令失败吗:任何 fail 都失败;--strict-gates 下 skipped 也算失败。"""
173
+ errs = ["[%s] %s %s" % (x["id"], x["title"],
174
+ ("— " + x["detail"]) if x["detail"] else "")
175
+ for x in self.items if x["status"] == FAIL]
176
+ if strict:
177
+ errs += ["[%s] %s:本次**没有执行**(--strict-gates 下算失败)"
178
+ % (x["id"], x["title"]) for x in self.items if x["status"] == SKIP]
179
+ return errs
180
+
181
+ # ---- 落盘 ----
182
+ def to_dict(self):
183
+ d = dict(schema=SCHEMA, at=self.t0, contentHash=self.content_hash,
184
+ engine=self.engine, counts=self.counts(),
185
+ items=list(self.items), bypassed=list(self.bypassed))
186
+ if self.engine == "web":
187
+ # 审计用:让用户/CI 能看出这个指纹到底盖了哪些页面脚本(不进 hash,绝对路径不外泄)
188
+ d["webAssets"] = list(self.web_assets)
189
+ return d
190
+
191
+ def write_json(self):
192
+ """work/gates.json —— 给程序读(QC-REPORT.md 只是它的渲染层)。"""
193
+ os.makedirs(self.work, exist_ok=True)
194
+ p = os.path.join(self.work, "gates.json")
195
+ d = self.to_dict()
196
+ d["wall_s"] = round(time.time() - self.t0, 2)
197
+ _atomic_json(p, d)
198
+ return p
199
+
200
+
201
+ # ---------------------------------------------------------------- 签核账本
202
+ def signoff_path(work):
203
+ return os.path.join(str(work), "signoff.json")
204
+
205
+
206
+ def read_signoff(work):
207
+ """读签核账本;坏 JSON 当作"没有签核",但把原因带出来(不静默吞掉)。"""
208
+ p = signoff_path(work)
209
+ if not os.path.exists(p):
210
+ return {"schema": SCHEMA, "signed": {}}, "没有 %s(还没人签核过)" % p
211
+ try:
212
+ d = json.load(open(p, encoding="utf-8"))
213
+ except (json.JSONDecodeError, OSError) as e:
214
+ return {"schema": SCHEMA, "signed": {}}, "%s 读不了(%s)——当作没有签核" % (p, e)
215
+ if not isinstance(d, dict) or not isinstance(d.get("signed"), dict):
216
+ return {"schema": SCHEMA, "signed": {}}, "%s 结构不对——当作没有签核" % p
217
+ return d, ""
218
+
219
+
220
+ def write_signoff(work, gid, content_hash, verdict="approved", by="", note="", artifacts=None):
221
+ """写一条人工签核。verdict=approved / rejected;rejected 视为没签。"""
222
+ if str(gid) not in GATES:
223
+ raise ValueError("未知的门禁 id %r(可选:%s)" % (gid, " / ".join(GATE_ORDER)))
224
+ d, _why = read_signoff(work)
225
+ d.setdefault("schema", SCHEMA)
226
+ d.setdefault("signed", {})
227
+ d["signed"][str(gid)] = dict(
228
+ verdict=str(verdict), at=time.time(),
229
+ atText=time.strftime("%Y-%m-%d %H:%M:%S"), by=str(by or ""),
230
+ contentHash=str(content_hash or ""), note=str(note or ""),
231
+ artifacts=[str(x) for x in (artifacts or [])],
232
+ )
233
+ os.makedirs(str(work), exist_ok=True)
234
+ _atomic_json(signoff_path(work), d)
235
+ return d["signed"][str(gid)]
236
+
237
+
238
+ def signoff_state(work, current_hash):
239
+ """返回 (每道闸的判定, 因内容变化而失效的闸列表)。
240
+
241
+ 判定只有三档:pass(签了且内容一致)/ fail(明确 rejected)/
242
+ skipped(没签过,或内容已变——"这一版还没签",不是"签坏了")。
243
+ """
244
+ d, why = read_signoff(work)
245
+ signed = (d.get("signed") or {}) if isinstance(d, dict) else {}
246
+ state, stale = {}, []
247
+ for gid in GATE_ORDER:
248
+ rec = signed.get(gid)
249
+ if not isinstance(rec, dict):
250
+ state[gid] = dict(status=SKIP, detail=why or "没签核过")
251
+ continue
252
+ if str(rec.get("verdict")) != "approved":
253
+ state[gid] = dict(status=FAIL, detail="签核结论是 %r" % rec.get("verdict"))
254
+ continue
255
+ if str(rec.get("contentHash") or "") != str(current_hash or ""):
256
+ state[gid] = dict(status=SKIP,
257
+ detail="内容已变(签核时 %s / 现在 %s),需重新签"
258
+ % (str(rec.get("contentHash"))[:8], str(current_hash)[:8]))
259
+ stale.append(gid)
260
+ continue
261
+ bits = [str(rec.get("atText") or "")]
262
+ if rec.get("by"):
263
+ bits.append(str(rec["by"]))
264
+ if rec.get("note"):
265
+ bits.append(str(rec["note"]))
266
+ state[gid] = dict(status=OK, detail=" 已签(%s)" % " · ".join(bits))
267
+ return state, stale
268
+
269
+
270
+ # ---------------------------------------------------------------- 耗时台账
271
+ def append_cost(work, stage, wall_s, **extra):
272
+ """work/cost.jsonl —— 每 stage 一行实测(排期公式从没被验证过,这是它的对照)。"""
273
+ os.makedirs(str(work), exist_ok=True)
274
+ row = dict(at=time.time(), atText=time.strftime("%Y-%m-%d %H:%M:%S"),
275
+ stage=str(stage), wall_s=round(float(wall_s), 3))
276
+ for k, v in extra.items():
277
+ if v is not None:
278
+ row[k] = v
279
+ p = os.path.join(str(work), "cost.jsonl")
280
+ try:
281
+ with open(p, "a", encoding="utf-8") as fh:
282
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
283
+ except OSError:
284
+ pass
285
+ return p
286
+
287
+
288
+ def read_cost(work):
289
+ p = os.path.join(str(work), "cost.jsonl")
290
+ rows = []
291
+ try:
292
+ with open(p, encoding="utf-8") as fh:
293
+ for line in fh:
294
+ line = line.strip()
295
+ if not line:
296
+ continue
297
+ try:
298
+ rows.append(json.loads(line))
299
+ except json.JSONDecodeError:
300
+ continue
301
+ except OSError:
302
+ return []
303
+ return rows
304
+
305
+
306
+ # ---------------------------------------------------------------- 报告
307
+ def _fmt_s(sec):
308
+ try:
309
+ sec = float(sec)
310
+ except (TypeError, ValueError):
311
+ return "-"
312
+ if sec < 60:
313
+ return "%.1fs" % sec
314
+ m, s = divmod(sec, 60)
315
+ if m < 60:
316
+ return "%dm%02ds" % (int(m), int(s))
317
+ h, m = divmod(m, 60)
318
+ return "%dh%02dm" % (int(h), int(m))
319
+
320
+
321
+ def render_report(work, out=None):
322
+ """把 work/gates.json + signoff + cost.jsonl 渲染成 out/QC-REPORT.md。"""
323
+ gj = os.path.join(str(work), "gates.json")
324
+ g = {}
325
+ if os.path.exists(gj):
326
+ try:
327
+ g = json.load(open(gj, encoding="utf-8"))
328
+ except (json.JSONDecodeError, OSError):
329
+ g = {}
330
+ ch = str(g.get("contentHash") or "")
331
+ items = list(g.get("items") or [])
332
+ counts = {OK: 0, FAIL: 0, SKIP: 0}
333
+ for x in items:
334
+ st = x.get("status", SKIP)
335
+ counts[st] = counts.get(st, 0) + 1
336
+ state, stale = signoff_state(work, ch)
337
+ signed_n = sum(1 for v in state.values() if v["status"] == OK)
338
+
339
+ L = []
340
+ L.append("# QC 报告(成片交付凭据)")
341
+ L.append("")
342
+ L.append("> 本文件由 `render.py --stage qa` 自动生成,是**机器可读的交付结论**。")
343
+ L.append("> 口径:`pass` = 本次真的查过且通过;`fail` = 查了没过;"
344
+ "`skipped` = **本次没执行**(不等于通过)。")
345
+ L.append("")
346
+ L.append("| 项 | 值 |")
347
+ L.append("|---|---|")
348
+ L.append("| 内容指纹 | `%s` |" % (ch or "未生成"))
349
+ L.append("| 引擎 | `%s` |" % (g.get("engine") or "-"))
350
+ L.append("| 生成时间 | %s |"
351
+ % time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(g.get("at") or time.time())))
352
+ L.append("| 门禁合计 | pass %d · fail %d · skipped %d |"
353
+ % (counts.get(OK, 0), counts.get(FAIL, 0), counts.get(SKIP, 0)))
354
+ L.append("| 人工签核 | %d/%d 有效 |" % (signed_n, len(GATE_ORDER)))
355
+ L.append("")
356
+ L.append("## 一、门禁矩阵")
357
+ L.append("")
358
+ if items:
359
+ L.append("| 门禁 | 判据 | 结果 | 实测 / 说明 |")
360
+ L.append("|---|---|---|---|")
361
+ for x in items:
362
+ L.append("| `%s` | %s | **%s** | %s |"
363
+ % (x.get("id", "?"), x.get("title", ""), x.get("status", "?"),
364
+ (x.get("detail") or "-").replace("|", "\\|")))
365
+ else:
366
+ L.append("(没有门禁记录:先跑 `--stage qa`)")
367
+ L.append("")
368
+ L.append("## 二、人工签核")
369
+ L.append("")
370
+ L.append("| 闸 | 要求 | 结果 | 说明 |")
371
+ L.append("|---|---|---|---|")
372
+ for gid in GATE_ORDER:
373
+ v = state.get(gid, {})
374
+ L.append("| `%s` | %s | **%s** | %s |"
375
+ % (gid, GATES[gid], v.get("status", "?"),
376
+ (v.get("detail") or "-").replace("|", "\\|")))
377
+ if stale:
378
+ L.append("")
379
+ L.append("> 内容已变的签核:%s(改了剧本 / scenes.py / 引擎 / web 页面脚本,"
380
+ "旧签核自动失效,需重新签)"
381
+ % "、".join("`%s`" % x for x in stale))
382
+ L.append("")
383
+ L.append("## 三、耗时台账(`work/cost.jsonl` 实测,非估算)")
384
+ L.append("")
385
+ rows = read_cost(work)
386
+ if rows:
387
+ L.append("| stage | 墙钟 | 备注 |")
388
+ L.append("|---|---|---|")
389
+ total = 0.0
390
+ for r in rows:
391
+ total += float(r.get("wall_s") or 0)
392
+ note = " · ".join("%s=%s" % (k, r[k])
393
+ for k in ("frames", "n_tts", "n_cues", "rework")
394
+ if r.get(k) not in (None, ""))
395
+ L.append("| `%s` | %s | %s |"
396
+ % (r.get("stage", "?"), _fmt_s(r.get("wall_s")), note or "-"))
397
+ L.append("| **合计** | **%s** | |" % _fmt_s(total))
398
+ else:
399
+ L.append("(还没有台账:首次运行或未接入 cost.jsonl)")
400
+ L.append("")
401
+ if g.get("bypassed"):
402
+ L.append("## 四、本次绕过的门禁(留痕,不是通过)")
403
+ L.append("")
404
+ for b in g["bypassed"]:
405
+ L.append("- %s%s" % (b.get("what"), (":" + b["why"]) if b.get("why") else ""))
406
+ L.append("")
407
+ nxt = "五"
408
+ else:
409
+ nxt = "四"
410
+ L.append("## %s、结论(照抄,别自己发挥)" % nxt)
411
+ L.append("")
412
+ if not items:
413
+ # 没有门禁记录 ≠ 全部通过。以前这里落到 else 分支打印"**通过**",
414
+ # 于是 gates.json 被删 / 写坏 / 根本没跑 qa 时,QC-REPORT.md 会是一份
415
+ # 宣称通过的交付凭据——正是本模块开头要防的"看起来干净、其实没在查"。
416
+ if not os.path.exists(gj):
417
+ why = "没有 %s(本次没有跑过任何 stage,或 work/ 目录已丢)" % gj
418
+ else:
419
+ why = "%s 读不出来 / 里面 0 条门禁(JSON 损坏或并发写坏)" % gj
420
+ L.append("- **未判定**:%s。**这不等于通过**——先跑 --stage qa(或 --stage all)"
421
+ "产出真实门禁再交付。" % why)
422
+ elif counts.get(FAIL):
423
+ L.append("- **未通过**:fail %d 项,按上表逐条修,别宣称完成。" % counts.get(FAIL))
424
+ elif counts.get(SKIP):
425
+ L.append("- **有条件通过**:skipped %d 项(明确没执行),交付时必须逐项说明原因。"
426
+ % counts.get(SKIP))
427
+ else:
428
+ L.append("- **通过**:所有门禁本次都执行且通过。")
429
+ L.append("- 报告:`%s`;机器可读源:`%s`。"
430
+ % (out or os.path.join("out", "QC-REPORT.md"), gj))
431
+ L.append("")
432
+ return "\n".join(L)
433
+
434
+
435
+ def write_report(work, out_path=None):
436
+ p = str(out_path or os.path.join(os.path.dirname(str(work)) or ".", "out", "QC-REPORT.md"))
437
+ d = os.path.dirname(os.path.abspath(p))
438
+ if d:
439
+ os.makedirs(d, exist_ok=True)
440
+ text = render_report(work, out=p)
441
+ with open(p, "w", encoding="utf-8") as fh:
442
+ fh.write(text)
443
+ return p
444
+
445
+
446
+ # ---------------------------------------------------------------- 原子写
447
+ def atomic_write_json(path, obj):
448
+ """先写 .tmp 再 os.replace:断电 / 被杀不会留下半截 JSON。"""
449
+ d = os.path.dirname(os.path.abspath(str(path)))
450
+ if d:
451
+ os.makedirs(d, exist_ok=True)
452
+ tmp = "%s.%d.tmp" % (str(path), os.getpid())
453
+ with open(tmp, "w", encoding="utf-8") as fh:
454
+ json.dump(obj, fh, ensure_ascii=False, indent=1)
455
+ os.replace(tmp, str(path))
456
+
457
+
458
+ def _atomic_json(path, obj):
459
+ return atomic_write_json(path, obj)
460
+
461
+
462
+ if __name__ == "__main__":
463
+ import argparse
464
+ import sys
465
+ ap = argparse.ArgumentParser(description="门禁账本 / 签核 / QC 报告")
466
+ ap.add_argument("--work", default="work")
467
+ ap.add_argument("--out", default="")
468
+ ap.add_argument("--accept", default="",
469
+ help="签核一道闸:%s" % " / ".join(GATE_ORDER))
470
+ ap.add_argument("--reject", default="", help="否决一道闸(同样记一条,但不算通过)")
471
+ ap.add_argument("--note", default="")
472
+ ap.add_argument("--by", default="")
473
+ ap.add_argument("--artifacts", default="", help="逗号分隔的证据文件路径")
474
+ ap.add_argument("--json", action="store_true", help="打印账本 JSON")
475
+ a = ap.parse_args()
476
+ try:
477
+ if a.accept or a.reject:
478
+ gid = (a.accept or a.reject).strip()
479
+ d, _why = read_signoff(a.work)
480
+ ch = ""
481
+ gj = os.path.join(a.work, "gates.json")
482
+ if os.path.exists(gj):
483
+ try:
484
+ ch = str(json.load(open(gj, encoding="utf-8")).get("contentHash") or "")
485
+ except (json.JSONDecodeError, OSError):
486
+ ch = ""
487
+ rec = write_signoff(a.work, gid, ch,
488
+ verdict="approved" if a.accept else "rejected",
489
+ by=a.by, note=a.note,
490
+ artifacts=[x.strip() for x in a.artifacts.split(",") if x.strip()])
491
+ print("[签核] %s = %s(内容指纹 %s)" % (gid, rec["verdict"], ch or "未知"))
492
+ p = write_report(a.work, a.out or None)
493
+ print("[QC 报告]", p)
494
+ if a.json:
495
+ # 读磁盘上的账本,别现场 new 一个空的:GateLedger(work) 不传剧本时
496
+ # contentHash 是按空剧本算的,打出来的 JSON 跟 work/gates.json 对不上、
497
+ # items 永远是 [] —— 机读输出等于撒谎。
498
+ gp = os.path.join(str(a.work), "gates.json")
499
+ try:
500
+ with open(gp, encoding="utf-8") as fh:
501
+ print(json.dumps(json.load(fh), ensure_ascii=False, indent=1))
502
+ except (OSError, json.JSONDecodeError) as err:
503
+ print(json.dumps(dict(ok=False, work=str(a.work),
504
+ error="没有可读的 %s:%s" % (gp, err)),
505
+ ensure_ascii=False, indent=1))
506
+ sys.exit(0)
507
+ except (ValueError, OSError) as e:
508
+ print("[ERROR] %s" % e, file=sys.stderr)
509
+ sys.exit(2)