Files
workinf_Blender_Wasm/tools/web/generate-re-pbr-narration.py
mes123456 d5dd647881
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
- Add 11:07 Chinese-narrated lesson video, VTT, poster and 13 chapter screenshots.
- Regenerate 20-render WebGL2 matrix and 15 operation captures on RTX 4060.
- Fix WASM staging for non-ASCII import filenames (猴头.glb); rebuild web_engine.
- Harden lesson video recording pipeline; fix stale lesson-link E2E selector.
- Add RE PBR derived libraries, lesson page, evidence and status report.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-12 21:49:24 +08:00

185 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Prepare Chinese narration and mix it into the RE 5000PBR lesson video.
Usage:
python3 generator.py --mode prepare \
--chapters /home/.../tools/web/re-pbr-video-chapters.json \
--out /home/.../tests/golden/RE-PBR-VIDEO-001
python3 generator.py --mode mix \
--out /home/.../tests/golden/RE-PBR-VIDEO-001 \
--video /home/.../web/app/public/tutorial/pbr/lesson/video/re-pbr-lesson-720p.mp4
"""
import argparse
import hashlib
import json
import pathlib
import re
import subprocess
import wave
EDGE_TTS = pathlib.Path("/home/mes123456/workinf_Blender_Wasm/tools/web/.tts-venv/bin/edge-tts")
FFMPEG = pathlib.Path("/usr/bin/ffmpeg")
FFPROBE = pathlib.Path("/usr/bin/ffprobe")
SAMPLE_RATE = 48000
SAMPLE_WIDTH = 2
CHANNELS = 1
def run(command, **kwargs):
return subprocess.run(command, check=True, capture_output=True, text=True, **kwargs)
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def prepare(chapters_path, output):
chapters = json.loads(chapters_path.read_text(encoding="utf-8"))
voice = chapters.get("voice", "zh-CN-XiaoxiaoNeural")
audio_dir = output / "narration"
audio_dir.mkdir(parents=True, exist_ok=True)
prepared = []
for chapter in chapters["chapters"]:
chapter_id = chapter["id"]
mp3 = audio_dir / f"{chapter_id}.mp3"
wav = audio_dir / f"{chapter_id}.wav"
if not mp3.exists():
run([str(EDGE_TTS), "--voice", voice, "--text", chapter["narration"], "--write-media", str(mp3)])
if not wav.exists() or wav.stat().st_mtime < mp3.stat().st_mtime:
run([str(FFMPEG), "-y", "-hide_banner", "-loglevel", "error", "-i", str(mp3),
"-ar", str(SAMPLE_RATE), "-ac", str(CHANNELS), "-c:a", "pcm_s16le", str(wav)])
duration = float(run([str(FFPROBE), "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", str(wav)]).stdout.strip())
prepared.append({
"id": chapter_id,
"title": chapter["title"],
"narration": chapter["narration"],
"mp3": str(mp3),
"wav": str(wav),
"durationMs": int(round(duration * 1000)),
})
result = {"voice": voice, "chapters": prepared}
(output / "narration.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
total = sum(item["durationMs"] for item in prepared)
print(json.dumps({"status": "PREPARED", "voice": voice, "chapters": len(prepared), "narrationMs": total}, ensure_ascii=False))
def read_pcm(path):
with wave.open(str(path), "rb") as handle:
if handle.getnchannels() != CHANNELS or handle.getsampwidth() != SAMPLE_WIDTH or handle.getframerate() != SAMPLE_RATE:
raise SystemExit(f"unexpected WAV format: {path}")
return handle.readframes(handle.getnframes())
def overlay(buffer, samples, offset_samples):
for index in range(0, len(samples), 2):
target = (offset_samples * 2) + index
if target + 1 >= len(buffer):
break
left = int.from_bytes(buffer[target:target + 2], "little", signed=True)
right = int.from_bytes(samples[index:index + 2], "little", signed=True)
mixed = max(-32768, min(32767, left + right))
buffer[target:target + 2] = mixed.to_bytes(2, "little", signed=True)
def timestamp(milliseconds):
seconds, ms = divmod(max(0, int(milliseconds)), 1000)
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{ms:03d}"
def mix(output, video):
timeline_path = output / "timeline.json"
narration_path = output / "narration.json"
if not timeline_path.exists():
raise SystemExit(f"missing recording timeline: {timeline_path}")
if not narration_path.exists():
raise SystemExit(f"missing narration: {narration_path}")
timeline = json.loads(timeline_path.read_text(encoding="utf-8"))
narration = json.loads(narration_path.read_text(encoding="utf-8"))
by_id = {chapter["id"]: chapter for chapter in narration["chapters"]}
total_ms = int(timeline.get("durationMs", 0))
for chapter in timeline.get("chapters", []):
total_ms = max(total_ms, int(chapter.get("endMs", 0)) + 800)
total_samples = int(total_ms / 1000 * SAMPLE_RATE)
buffer = bytearray(total_samples * 2)
cues = []
for chapter in timeline.get("chapters", []):
prepared = by_id.get(chapter["id"])
if not prepared:
continue
start_ms = int(chapter.get("startMs", 0))
samples = read_pcm(pathlib.Path(prepared["wav"]))
overlay(buffer, samples, int(start_ms / 1000 * SAMPLE_RATE))
cues.append({
"id": chapter["id"],
"title": prepared["title"],
"startMs": start_ms,
"endMs": start_ms + prepared["durationMs"],
"narration": prepared["narration"],
})
narration_wav = output / "narration.wav"
with wave.open(str(narration_wav), "wb") as handle:
handle.setnchannels(CHANNELS)
handle.setsampwidth(SAMPLE_WIDTH)
handle.setframerate(SAMPLE_RATE)
handle.writeframes(bytes(buffer))
vtt = ["WEBVTT", ""]
for cue in cues:
vtt.extend([f"{timestamp(cue['startMs'])} --> {timestamp(cue['endMs'])}", f"{cue['title']}:{cue['narration']}", ""])
vtt_path = video.parent / "re-pbr-lesson.zh-Hans.vtt"
vtt_path.write_text("\n".join(vtt), encoding="utf-8")
video.parent.mkdir(parents=True, exist_ok=True)
run([str(FFMPEG), "-y", "-hide_banner", "-loglevel", "error", "-i", str(output / "screencast.webm"),
"-i", str(narration_wav), "-vf", "scale=1280:720", "-c:v", "libx264", "-crf", "23", "-preset", "medium",
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-shortest", str(video)])
poster = video.parent / "poster.jpg"
poster_ms = 1000
for chapter in timeline.get("chapters", []):
if chapter.get("id") == "hq-2048":
poster_ms = max(1000, min(int(chapter.get("endMs", 0)) - 8000,
int(timeline.get("durationMs", 0)) - 2000))
run([str(FFMPEG), "-y", "-hide_banner", "-loglevel", "error", "-ss", f"{poster_ms / 1000:.3f}", "-i", str(video),
"-frames:v", "1", "-vf", "scale=1280:720", str(poster)])
probe = run([str(FFPROBE), "-v", "error", "-show_entries",
"format=duration,size:stream=codec_type,codec_name,width,height", "-of", "json", str(video)])
(output / "video-probe.json").write_text(probe.stdout + "\n", encoding="utf-8")
report = {
"schemaVersion": 1,
"status": "PASS",
"voice": narration.get("voice"),
"video": {"path": str(video), "sha256": sha256(video), "bytes": video.stat().st_size},
"narration": {"path": str(narration_wav), "sha256": sha256(narration_wav), "bytes": narration_wav.stat().st_size},
"vtt": {"path": str(vtt_path), "sha256": sha256(vtt_path), "cues": len(cues)},
"poster": {"path": str(poster), "sha256": sha256(poster)},
"chapters": cues,
}
(output / "video-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"status": "MIXED", "video": str(video), "bytes": video.stat().st_size,
"duration": json.loads(probe.stdout).get("format", {}).get("duration"), "cues": len(cues)}, ensure_ascii=False))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["prepare", "mix"], required=True)
parser.add_argument("--chapters", default="/home/mes123456/workinf_Blender_Wasm/tools/web/re-pbr-video-chapters.json")
parser.add_argument("--out", default="/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001")
parser.add_argument("--video", default="/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/video/re-pbr-lesson-720p.mp4")
args = parser.parse_args()
output = pathlib.Path(args.out)
output.mkdir(parents=True, exist_ok=True)
if args.mode == "prepare":
prepare(pathlib.Path(args.chapters), output)
else:
mix(output, pathlib.Path(args.video))
if __name__ == "__main__":
main()