ahp-development-workstation / skill /scripts /validate_release.py
cpuai's picture
Install complete Linux VS Code + AHP MCP development workstation 2026-10-12
c12eb19 verified
Raw History Blame Contribute Delete
6.72 kB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""用 ffprobe 检查成品,生成报告并创建独立、无密钥的源码包和完整发布包。"""
import argparse
import hashlib
import json
import re
import subprocess
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STORY = json.loads((ROOT / "story.json").read_text(encoding="utf-8"))
TIMING = json.loads((ROOT / "timing.json").read_text(encoding="utf-8"))
OUT = ROOT / "output"
VIDEO = OUT / f"{STORY['slug']}.mp4"
def parse_probe(path: Path) -> dict:
stdout = subprocess.check_output(["ffprobe", "-v", "error", "-show_format", "-show_streams",
"-of", "json", str(path)], text=True)
return json.loads(stdout)
def inspect() -> dict:
if not VIDEO.is_file() or VIDEO.stat().st_size < 100_000:
raise FileNotFoundError(f"真正渲染的视频不存在或不完整: {VIDEO}")
data = parse_probe(VIDEO)
video = next((s for s in data["streams"] if s["codec_type"] == "video"), None)
audio = next((s for s in data["streams"] if s["codec_type"] == "audio"), None)
duration = float(data["format"]["duration"])
width = int(STORY["video"]["width"])
height = int(STORY["video"]["height"])
fps = int(STORY["video"]["fps"])
actual_fps = (video.get("avg_frame_rate") or video.get("r_frame_rate") or "0/1") if video else "0/1"
values = actual_fps.split("/")
actual_rate = float(values[0]) / max(float(values[1]), .0001)
caption_file = OUT / f"{STORY['slug']}.srt"
snapshots = list((OUT / "snapshots").glob("*.png"))
snapshots += list((OUT / "snapshots-fixed").glob("*.png"))
technical = bool(
video and audio and video.get("codec_name") == "h264" and
audio.get("codec_name") == "aac" and int(video["width"]) == width and
int(video["height"]) == height and abs(actual_rate - fps) < .1 and
abs(duration - float(TIMING["duration"])) < .12 and
caption_file.is_file() and caption_file.stat().st_size > 10
)
from narrate import validate_story, clean
subtitles = json.loads((ROOT/'captions.json').read_text(encoding='utf-8'))
expected = clean(''.join(line['speech'] for group in validate_story(STORY) for line in group))
emitted = clean(''.join(c.get('text','') for c in subtitles))
exact_subtitles = bool(expected == emitted and len(subtitles)>0 and
all(c.get('precise_tts_boundaries') is True for c in subtitles) and
TIMING.get('verbatim_captions') is True and not TIMING.get('warnings'))
if not exact_subtitles:
raise ValueError('配音原稿与字幕文字/词边界不一致,拒绝打包。请重新生成真实配音字幕。')
has_snapshots = len(snapshots) >= 3
has_real_voice = not TIMING.get("mock", False)
h = hashlib.sha256()
with VIDEO.open("rb") as f:
for chunk in iter(lambda: f.read(2 * 1024 * 1024), b""):
h.update(chunk)
result = {
"file": str(VIDEO), "bytes": VIDEO.stat().st_size, "sha256": h.hexdigest(),
"duration": round(duration, 6), "width": video.get("width") if video else None,
"height": video.get("height") if video else None, "fps": actual_fps,
"video_codec": video.get("codec_name") if video else None,
"audio_codec": audio.get("codec_name") if audio else None,
"audio_rate": audio.get("sample_rate") if audio else None,
"snapshot_count": len(snapshots), "tts_mock": TIMING.get("mock", False),
"subtitle_verbatim_pass": exact_subtitles,
"technical_pass": technical,
"has_story_snapshots": has_snapshots,
"manual_science_review_required": True,
"qa_pass": bool(technical and has_snapshots and has_real_voice),
"manual_review": "required: watch video and listen before classroom use",
"warnings": TIMING.get("warnings", [])
}
OUT.mkdir(exist_ok=True)
(OUT / "validation.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
return result
def eligible_files(full: bool):
allowed = ["README.md", "requirements.txt", "story.json", "timing.json", "captions.json", "meta.json",
"package.json", "index.html"]
found = [ROOT / n for n in allowed if (ROOT / n).is_file()]
for dirname in ("scripts", "assets"):
found.extend(p for p in (ROOT / dirname).rglob("*") if p.is_file() and "__pycache__" not in p.parts)
found.extend([OUT / f"{STORY['slug']}.srt", OUT / "validation.json"])
if full:
found.append(VIDEO)
for sn in ("snapshots", "snapshots-fixed"):
found.extend(p for p in (OUT / sn).glob("*.png") if p.is_file())
for p in found:
if (p.is_file() and not p.name.startswith(".env") and p.suffix.lower() not in
(".pyc", ".pem", ".key", ".pfx", ".ttf", ".otf", ".woff", ".woff2") and ".git" not in p.parts):
yield p
def pack_release() -> list[dict]:
packages = []
for full in (False, True):
target = OUT / f"{STORY['slug']}-{'complete' if full else 'source'}.zip"
members = list(eligible_files(full))
with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as z:
for p in members:
z.write(p, (ROOT.name + "/" + p.relative_to(ROOT).as_posix()))
with zipfile.ZipFile(target) as z:
if z.testzip() is not None:
raise ValueError(f"损坏的 ZIP: {target}")
packages.append({"file": str(target), "size_bytes": target.stat().st_size,
"entries": len(members)})
return packages
def main() -> None:
parser = argparse.ArgumentParser(description="检查成片、生成 validation.json、打包")
parser.add_argument("--inspect-only", action="store_true", help="只检查和输出报告,不打包")
opts = parser.parse_args()
result = inspect()
print(json.dumps(result, ensure_ascii=False, indent=2))
if not result["qa_pass"]:
issues = []
if not result["technical_pass"]:
issues.append("ffprobe 媒体规格/时长/字幕文件错误")
if not result["has_story_snapshots"]:
issues.append("缺少至少3张视频关键帧")
if result["tts_mock"]:
issues.append("仍使用模拟配音,禁止发布")
raise SystemExit("不能发布:" + ";".join(issues))
if not opts.inspect_only:
print(json.dumps({"packages": pack_release()}, ensure_ascii=False, indent=2))
print("技术检查通过;仍需人工完整观看试听后才能作为课堂正式成品。")
if __name__ == "__main__":
main()