#!/usr/bin/env python3 """Capture an already positioned spectator through the authenticated local worker. The server/operator must teleport the observer first. This helper cannot teleport or edit a world and does not extend the MCP project's authorized world boundary. Useful for operator-side checks of separate initial-generation worlds. The normal MCP camera_capture tool continues to use Paper's project-scoped route. """ import argparse import base64 import hashlib import importlib.util import json import os from pathlib import Path import re import time import urllib.request ROOT = Path(__file__).resolve().parents[1] RUNTIME = Path(os.environ.get('MCB_RUNTIME_DIR') or ROOT / '.runtime').expanduser().resolve() # Share bounded transport/PNG checks; importing this module does not run its test. spec = importlib.util.spec_from_file_location('camera_checks', ROOT / 'scripts/live-camera-test.py') checks = importlib.util.module_from_spec(spec) spec.loader.exec_module(checks) def validate_request(pose): """Validate locally before reading credentials or sending a capture request.""" values = [pose.get(key) for key in ('x', 'y', 'z', 'yaw', 'pitch')] if any(type(value) not in (int, float) for value in values): raise checks.TestFailure('Pose values must all be numbers.') checks.validate_pose(values) dimension = pose.get('dimension') if not isinstance(dimension, str) or len(dimension) > 128 or not re.fullmatch(r'[a-z0-9_.-]+:[a-z0-9_./-]+', dimension): raise checks.TestFailure('Dimension must be a namespaced Minecraft dimension key.') for key, fallback, low, high in (('fov', 70, 30, 110), ('width', 1280, 320, 1920), ('height', 720, 180, 1080)): value = pose.get(key, fallback) if type(value) is not int or not low <= value <= high: raise checks.TestFailure(f'{key} must be an integer in {low}..{high}.') def validated_image(result, capture_id, pose, token, started): if result.get('captureId') != capture_id or result.get('mimeType') != 'image/png': raise checks.TestFailure('Completed capture identity or MIME type did not match.') if result.get('dimension') != pose['dimension']: raise checks.TestFailure('Completed capture came from a different dimension.') captured_at = checks.instant(result.get('capturedAt'), 'Capture timestamp') if captured_at < started - 1 or captured_at > time.time() + 5: raise checks.TestFailure('The frame timestamp does not correspond to this capture request.') encoded = result.get('imageBase64') if not isinstance(encoded, str) or len(encoded) > 12_000_000: raise checks.TestFailure('Completed capture did not include a bounded image payload.') try: raw = base64.b64decode(encoded, validate=True) except ValueError: raise checks.TestFailure('Completed capture contained invalid image encoding.') from None width, height = checks.inspect_png(raw) if result.get('width') != width or result.get('height') != height: raise checks.TestFailure('PNG dimensions disagree with the capture metadata.') metadata = {key: value for key, value in result.items() if key in checks.SAFE_METADATA} if any(not isinstance(value, (str, int, float, bool, type(None))) or isinstance(value, str) and len(value) > 1024 for value in metadata.values()): raise checks.TestFailure('Capture metadata contains unexpected values.') metadata.update(operator_direct_worker=True, imageSha256=hashlib.sha256(raw).hexdigest(), imageBytes=len(raw)) try: receipt = json.dumps(metadata, indent=2, allow_nan=False) + '\n' except ValueError: raise checks.TestFailure('Capture metadata contains nonfinite values.') from None if token in receipt: raise checks.TestFailure('Refusing to save metadata containing a component secret.') return raw, receipt def capture(config, pose, output): validate_request(pose) try: source = Path(config).expanduser().read_text() token = checks.scalar(source, 'camera-token') port = int(checks.scalar(source, 'camera-port')) except (OSError, UnicodeError, ValueError): raise checks.TestFailure('Cannot read a valid private camera configuration.') from None if not 1024 <= port <= 65535 or not re.fullmatch(r'[A-Za-z0-9._~-]{32,512}', token): raise checks.TestFailure('Invalid private camera configuration.') opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), checks.NoRedirect()) endpoint = f'http://127.0.0.1:{port}' started, deadline = time.time(), time.monotonic() + 30 result = checks.request_json(opener, endpoint + '/v1/capture', token, pose, timeout=25) capture_id = checks.identifier(result.get('captureId'), 'Capture ID') while result.get('status') in ('pending', 'queued', 'capturing'): remaining = deadline - time.monotonic() if remaining <= 0: raise checks.TestFailure('Camera did not complete in time.') time.sleep(min(.3, remaining)) result = checks.request_json(opener, endpoint + '/v1/captures/' + capture_id, token, timeout=min(10, remaining)) if result.get('captureId') != capture_id: raise checks.TestFailure('Camera returned a different capture identity.') if result.get('status') != 'completed': code = result.get('error', '') safe_code = code if isinstance(code, str) and re.fullmatch(r'[a-z_]{1,64}', code) else 'capture_failed' raise checks.TestFailure('Camera unavailable: ' + safe_code) raw, receipt = validated_image(result, capture_id, pose, token, started) output = Path(output).expanduser() output.parent.mkdir(parents=True, exist_ok=True) output.write_bytes(raw) output.with_suffix('.capture.json').write_text(receipt) print(json.dumps({'path': str(output.resolve()), 'dimension': result.get('dimension'), 'status': 'completed'})) if __name__ == '__main__': p = argparse.ArgumentParser(description=__doc__) p.add_argument('--config', type=Path, default=Path(os.environ.get('MCB_CAMERA_PAPER_CONFIG') or RUNTIME / 'server/plugins/MinecraftBuilderMCP/config.yml')) p.add_argument('--pose', type=float, nargs=5, required=True, metavar=('X', 'Y', 'Z', 'YAW', 'PITCH')) p.add_argument('--dimension', required=True) p.add_argument('--fov', type=int, default=80) p.add_argument('--out', type=Path, required=True) a = p.parse_args() try: capture(a.config, dict(zip(('x', 'y', 'z', 'yaw', 'pitch'), a.pose)) | {'dimension': a.dimension, 'fov': a.fov, 'width': 1280, 'height': 720}, a.out) except checks.TestFailure as error: raise SystemExit(str(error)) from None