Separate map projects and add reusable island and operator camera tools
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user