Spaces:
Sleeping
Sleeping
File size: 6,721 Bytes
c12eb19 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | #!/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()
|