Files
workinf_Blender_Wasm/tools/web/capture-native-last-session.py
mes123456 f3e21c6b98
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
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

64 lines
6.6 KiB
Python

"""Capture one native GUI quit plus independent background readback; never claim release acceptance."""
import argparse, datetime, hashlib, json, os, pathlib, shutil, signal, subprocess
ROOT = pathlib.Path(__file__).resolve().parents[2]
parser = argparse.ArgumentParser()
parser.add_argument("--blender", default=str(ROOT / "build_blender_native/bin/blender"))
parser.add_argument("--input", required=True); parser.add_argument("--output", required=True)
parser.add_argument("--timeout", type=int, default=300)
args = parser.parse_args(); native_binary = pathlib.Path(args.blender).resolve(); asset_input = pathlib.Path(args.input).resolve(); output = pathlib.Path(args.output).resolve()
def sha(path):
digest = hashlib.sha256()
with pathlib.Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1048576), b""): digest.update(chunk)
return digest.hexdigest()
producer = ROOT / "web/tests/fixtures/native-last-session-prepare.py"; reader = ROOT / "web/tests/fixtures/native-last-session-readback.py"
companion = asset_input.with_name("ExternalImage.png")
assert 0 < args.timeout <= 1800 and asset_input.suffix.lower() == ".blend" and all(path.is_file() for path in (native_binary, asset_input, companion, producer, reader)) and os.access(native_binary, os.X_OK), "Native executable, source fixtures, input blend and original image companion are required"
environment = dict(os.environ); gui_prefix = []
if not (environment.get("DISPLAY") or environment.get("WAYLAND_DISPLAY")):
xvfb = shutil.which("xvfb-run"); assert xvfb, "GUI quit capture requires a live display or existing xvfb-run"
gui_prefix = [xvfb, "-a"]; environment.setdefault("LIBGL_ALWAYS_SOFTWARE", "1")
output.mkdir(parents=True, exist_ok=False); assets = output / "assets"; assets.mkdir()
working_input = assets / asset_input.name; shutil.copy2(asset_input, working_input); shutil.copy2(companion, assets / companion.name)
work = output / "native-work"; receipt_path = output / "desktop-baseline.json"
state = {"schemaVersion": 1, "kind": "desktop-native-quit-baseline", "phase": "PREPARED", "acceptance": "NOT_CLAIMED", "workDir": str(work), "nativeBinary": {"path": str(native_binary), "sha256": sha(native_binary)}, "sources": {name: {"path": str(path), "sha256": sha(path)} for name, path in (("producer", producer), ("reader", reader), ("capture", pathlib.Path(__file__).resolve()))}, "input": {"source": str(asset_input), "path": str(working_input), "sha256": sha(working_input), "companion": {"source": str(companion), "path": str(assets / companion.name), "sha256": sha(assets / companion.name)}}, "steps": {}}
def persist():
temporary = receipt_path.with_suffix(".tmp"); temporary.write_text(json.dumps(state, indent=2) + "\n"); temporary.replace(receipt_path)
def terminate_owned(process):
for sig, grace in ((signal.SIGTERM, 5), (signal.SIGKILL, 5)):
try: os.killpg(process.pid, sig)
except ProcessLookupError: pass
try: process.wait(timeout=grace); return
except subprocess.TimeoutExpired: continue
state["cleanupPendingPid"] = process.pid; persist(); raise RuntimeError(f"Owned process {process.pid} remains live after bounded cleanup")
def run_step(label, command):
log = output / f"{label}.log"; step = {"command": command, "log": str(log), "startedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), "timeoutSeconds": args.timeout}; state["steps"][label] = step; state["phase"] = label.upper() + "_RUNNING"; persist()
with log.open("w") as stream:
process = subprocess.Popen(command, cwd=ROOT, env=environment, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True)
step["pid"] = process.pid; persist()
try: step["exitCode"] = process.wait(timeout=args.timeout)
except subprocess.TimeoutExpired:
step["timedOut"] = True; terminate_owned(process); step["exitCode"] = process.returncode
except BaseException:
terminate_owned(process); raise
step["finishedAt"] = datetime.datetime.now(datetime.timezone.utc).isoformat(); step["logSha256"] = sha(log); persist()
if step.get("timedOut") or step["exitCode"] != 0: raise RuntimeError(f"{label} failed or timed out; see {log}")
def capture():
persist()
run_step("gui-producer", gui_prefix + [str(native_binary), "--factory-startup", "--disable-autoexec", "--python-exit-code", "1", "--python", str(producer), "--", "--input", str(working_input), "--work-dir", str(work)])
quit_file = work / "native-temp/quit.blend"; assert quit_file.is_file() and quit_file.stat().st_size > 0, "GUI exit did not produce quit.blend"; state["quitFile"] = {"path": str(quit_file), "bytes": quit_file.stat().st_size, "sha256": sha(quit_file)}; persist()
run_step("background-reader", [str(native_binary), "--background", "--factory-startup", "--disable-autoexec", "--python-exit-code", "1", "--python", str(reader), "--", "--work-dir", str(work)])
readback_path = work / "native-last-session-readback.json"; readback = json.loads(readback_path.read_text())
assert readback["kind"] == "desktop-native-quit-readback" and readback["phase"] == "NATIVE_QUIT_READBACK_CAPTURED" and readback["nativeBinary"] == str(native_binary) and readback["quitFile"] == state["quitFile"] and sha(quit_file) == state["quitFile"]["sha256"]
state["readback"] = {"path": str(readback_path), "sha256": sha(readback_path)}; state["version"] = readback["version"]; state["platform"] = readback["platform"]
assert readback["inputSha256"] == state["input"]["sha256"] and readback["producerSha256"] == state["sources"]["producer"]["sha256"] and readback["readerSha256"] == state["sources"]["reader"]["sha256"] and sha(work / "native-last-session-expected.json") == readback["expectedReceiptSha256"]
assert sha(native_binary) == state["nativeBinary"]["sha256"] and all(sha(item["path"]) == item["sha256"] for item in state["sources"].values()), "Native/source inputs changed during capture"
assert sha(asset_input) == sha(working_input) == state["input"]["sha256"] and sha(companion) == sha(assets / companion.name) == state["input"]["companion"]["sha256"], "Original or working assets changed"
state["phase"] = "DESKTOP_BASELINE_CAPTURED"; state["finishedAt"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
persist(); print(str(receipt_path), flush=True)
def capture_interrupted(signum, frame): raise KeyboardInterrupt(f"Capture interrupted by signal {signum}")
signal.signal(signal.SIGTERM, capture_interrupted)
try: capture()
except BaseException as error:
state["phase"] = "FAILED_WITH_OWNED_PROCESS_PENDING" if state.get("cleanupPendingPid") else "FAILED"; state["error"] = str(error); persist(); raise