- 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>
185 lines
8.3 KiB
Python
185 lines
8.3 KiB
Python
#!/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()
|