Initial minecraft-builder-mcp prototype and Gothic hall build
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
package dev.minecraftbuilder.camera;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.mojang.blaze3d.platform.NativeImage;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.minecraft.client.CameraType;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.Screenshot;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** One observer, one active request, bounded retained screenshots. No world writes or client teleport hacks. */
|
||||
public final class CameraClient implements ClientModInitializer, CameraHttpServer.Backend {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("minecraft-builder-camera");
|
||||
private static final long TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(20);
|
||||
private static volatile CameraClient instance;
|
||||
private final AtomicReference<Job> active = new AtomicReference<>();
|
||||
private final LinkedHashMap<String, Job> jobs = new LinkedHashMap<>();
|
||||
private final ExecutorService encoder = Executors.newSingleThreadExecutor(Thread.ofPlatform()
|
||||
.daemon(true).name("mcb-camera-encoder").factory());
|
||||
private final ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform()
|
||||
.daemon(true).name("mcb-camera-watchdog").factory());
|
||||
private volatile JsonObject cachedHealth = CameraHttpServer.error("starting", "Waiting for client tick");
|
||||
private CameraHttpServer http;
|
||||
|
||||
@Override public void onInitializeClient() {
|
||||
String token = System.getenv("MCB_CAMERA_TOKEN");
|
||||
if (token == null || token.isBlank()) {
|
||||
LOGGER.info("Camera HTTP disabled: set MCB_CAMERA_TOKEN to enable the dedicated observer service");
|
||||
encoder.shutdownNow();
|
||||
watchdog.shutdownNow();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int port = Integer.parseInt(System.getenv().getOrDefault("MCB_CAMERA_PORT", "8766"));
|
||||
if (port < 1024 || port > 65535) throw new IllegalArgumentException("Invalid camera port");
|
||||
http = new CameraHttpServer(port, token, this);
|
||||
instance = this;
|
||||
ClientTickEvents.END_CLIENT_TICK.register(this::tick);
|
||||
ClientLifecycleEvents.CLIENT_STOPPING.register(this::stop);
|
||||
watchdog.scheduleAtFixedRate(this::expire, 1, 1, TimeUnit.SECONDS);
|
||||
http.start();
|
||||
LOGGER.info("Camera HTTP listening on 127.0.0.1:{} (authenticated)", port);
|
||||
} catch (Exception exception) {
|
||||
LOGGER.error("Camera service could not start: {}", exception.getClass().getSimpleName());
|
||||
encoder.shutdownNow();
|
||||
watchdog.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public JsonObject health() { return cachedHealth.deepCopy(); }
|
||||
|
||||
@Override public synchronized JsonObject submit(CaptureRequest request) {
|
||||
Job job = new Job(request);
|
||||
if (!active.compareAndSet(null, job)) return CameraHttpServer.error("camera_busy", "One capture is already active");
|
||||
jobs.put(job.id, job);
|
||||
while (jobs.size() > 4) jobs.remove(jobs.keySet().iterator().next());
|
||||
return job.result.deepCopy();
|
||||
}
|
||||
|
||||
@Override public synchronized JsonObject poll(String captureId) {
|
||||
Job job = jobs.get(captureId);
|
||||
return job == null ? null : job.result.deepCopy();
|
||||
}
|
||||
|
||||
private synchronized void expire() {
|
||||
Job job = active.get();
|
||||
if (job != null && System.nanoTime() - job.createdNanos >= TIMEOUT_NANOS)
|
||||
job.fail("capture_timeout", "Scene did not become ready within 20 seconds; no fresh image returned");
|
||||
// Keep an expired active job until a client tick can restore its view safely.
|
||||
jobs.values().removeIf(value -> value != active.get()
|
||||
&& System.nanoTime() - value.createdNanos > TimeUnit.MINUTES.toNanos(2));
|
||||
}
|
||||
|
||||
private void tick(Minecraft client) {
|
||||
Job job = active.get();
|
||||
JsonObject health = new JsonObject();
|
||||
health.addProperty("status", "ok");
|
||||
health.addProperty("connected", client.level != null && client.player != null);
|
||||
health.addProperty("spectator", client.player != null && client.player.isSpectator());
|
||||
health.addProperty("busy", job != null);
|
||||
health.addProperty("updatedAt", Instant.now().toString());
|
||||
if (client.level != null) health.addProperty("dimension", dimension(client));
|
||||
if (client.player != null) health.addProperty("playerId", client.player.getUUID().toString());
|
||||
cachedHealth = health;
|
||||
if (job == null) return;
|
||||
if (job.done) { restore(client, job); active.compareAndSet(job, null); return; }
|
||||
if (client.player == null || client.level == null) { job.fail("disconnected", "Camera client is not in a world"); return; }
|
||||
if (!client.player.isSpectator()) { job.fail("spectator_required", "Camera account must be in spectator mode"); return; }
|
||||
if (client.gui.screen() != null || client.gui.overlay() != null || client.isPaused()) {
|
||||
job.fail("view_obstructed", "Close menus and overlays in the observer client"); return;
|
||||
}
|
||||
if (!job.initialized) {
|
||||
// Paper owns teleports. Wait for its position/dimension packet before adjusting the view.
|
||||
if (!matchesPosition(client, job.request)) return;
|
||||
job.player = client.player;
|
||||
job.level = client.level;
|
||||
job.saved = new SavedView(client.gui.hud.isHidden(), client.options.fov().get(),
|
||||
client.options.bobView().get(), client.options.fovEffectScale().get(),
|
||||
client.options.getCameraType(), client.player.getYRot(), client.player.getXRot());
|
||||
if (!client.gui.hud.isHidden()) client.gui.hud.toggle();
|
||||
client.options.fov().set(job.request.fov());
|
||||
client.options.bobView().set(false);
|
||||
client.options.fovEffectScale().set(0.0);
|
||||
client.options.setCameraType(CameraType.FIRST_PERSON);
|
||||
client.player.setYRot(job.request.yaw());
|
||||
client.player.setXRot(job.request.pitch());
|
||||
client.player.setOldRot();
|
||||
job.initialized = true;
|
||||
}
|
||||
if (client.player != job.player || client.level != job.level || !matchesPosition(client, job.request)) {
|
||||
job.fail("camera_moved", "Observer moved or changed world during capture"); return;
|
||||
}
|
||||
if (client.getCameraEntity() != client.player) {
|
||||
job.fail("spectating_entity", "Observer must use its own camera, not another entity"); return;
|
||||
}
|
||||
if (!matchesView(client, job.request)) { job.fail("view_changed", "Observer view changed during capture"); return; }
|
||||
if (chunksLoaded(client)) job.stableTicks++; else { job.stableTicks = 0; job.readyFrames = 0; }
|
||||
}
|
||||
|
||||
/** Called after GameRenderer.render, on Minecraft's render thread. Uses the supported GPU screenshot API. */
|
||||
public static void afterRender(boolean renderWorld) {
|
||||
CameraClient worker = instance;
|
||||
if (worker != null && renderWorld) worker.rendered(Minecraft.getInstance());
|
||||
}
|
||||
|
||||
private void rendered(Minecraft client) {
|
||||
Job job = active.get();
|
||||
if (job == null || job.done || !job.initialized || job.readbackStarted || job.stableTicks < 20) return;
|
||||
if (client.player != job.player || client.level != job.level || client.gui.screen() != null
|
||||
|| client.gui.overlay() != null || !matchesPosition(client, job.request) || !matchesView(client, job.request)) {
|
||||
job.fail("view_changed", "Observer view changed before frame capture"); return;
|
||||
}
|
||||
if (!client.gameRenderer.mainCamera().isInitialized() || !chunksLoaded(client)
|
||||
|| client.levelRenderer.sectionRenderDispatcher() == null || !client.levelRenderer.hasRenderedAllSections()) {
|
||||
job.readyFrames = 0; return;
|
||||
}
|
||||
if (++job.readyFrames < 3) return;
|
||||
var target = client.gameRenderer.mainRenderTarget();
|
||||
if (target.width <= 0 || target.height <= 0 || (long) target.width * target.height > 16_777_216) {
|
||||
job.fail("framebuffer_size", "Observer framebuffer is empty or exceeds 16 megapixels"); return;
|
||||
}
|
||||
job.readbackStarted = true;
|
||||
JsonObject metadata = new JsonObject();
|
||||
metadata.addProperty("capturedAt", Instant.now().toString());
|
||||
metadata.addProperty("dimension", dimension(client));
|
||||
metadata.addProperty("x", client.player.getX());
|
||||
metadata.addProperty("y", client.player.getY());
|
||||
metadata.addProperty("z", client.player.getZ());
|
||||
metadata.addProperty("eyeY", client.gameRenderer.mainCamera().position().y);
|
||||
metadata.addProperty("yaw", client.gameRenderer.mainCamera().yRot());
|
||||
metadata.addProperty("pitch", client.gameRenderer.mainCamera().xRot());
|
||||
metadata.addProperty("fov", job.request.fov());
|
||||
metadata.addProperty("readiness", "local_chunks_and_render_queue_stable");
|
||||
metadata.addProperty("serverRevisionVerified", false);
|
||||
metadata.addProperty("loadedChunkRadius", 1);
|
||||
metadata.addProperty("stabilizationTicks", job.stableTicks);
|
||||
metadata.addProperty("stabilizationFrames", job.readyFrames);
|
||||
if (job.request.afterOperationId() != null) metadata.addProperty("afterOperationId", job.request.afterOperationId());
|
||||
try {
|
||||
Screenshot.takeScreenshot(target, image -> {
|
||||
if (job.done || encoder.isShutdown()) { image.close(); return; }
|
||||
try { encoder.execute(() -> encode(job, image, metadata)); }
|
||||
catch (RuntimeException error) { image.close(); job.fail("encoding_unavailable", "Image encoder unavailable"); }
|
||||
});
|
||||
} catch (Exception exception) {
|
||||
job.fail("readback_failed", "Could not read observer framebuffer");
|
||||
}
|
||||
}
|
||||
|
||||
private static void encode(Job job, NativeImage image, JsonObject metadata) {
|
||||
try (image; ByteArrayOutputStream bytes = new ByteArrayOutputStream()) {
|
||||
if (job.done) return;
|
||||
int sourceWidth = image.getWidth(), sourceHeight = image.getHeight();
|
||||
double scale = Math.min(1, Math.min((double) job.request.width() / sourceWidth,
|
||||
(double) job.request.height() / sourceHeight));
|
||||
int width = Math.max(1, (int) Math.round(sourceWidth * scale));
|
||||
int height = Math.max(1, (int) Math.round(sourceHeight * scale));
|
||||
BufferedImage source = new BufferedImage(sourceWidth, sourceHeight, BufferedImage.TYPE_INT_ARGB);
|
||||
source.setRGB(0, 0, sourceWidth, sourceHeight, image.getPixels(), 0, sourceWidth);
|
||||
BufferedImage output = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = output.createGraphics();
|
||||
try {
|
||||
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
||||
graphics.drawImage(source, 0, 0, width, height, null);
|
||||
} finally { graphics.dispose(); source.flush(); }
|
||||
if (!ImageIO.write(output, "png", bytes)) throw new IllegalStateException("PNG writer unavailable");
|
||||
output.flush();
|
||||
if (bytes.size() > 8 * 1024 * 1024) { job.fail("image_too_large", "PNG exceeds 8 MiB limit"); return; }
|
||||
JsonObject result = metadata.deepCopy();
|
||||
result.addProperty("status", "completed");
|
||||
result.addProperty("mimeType", "image/png");
|
||||
result.addProperty("width", width);
|
||||
result.addProperty("height", height);
|
||||
result.addProperty("sourceWidth", sourceWidth);
|
||||
result.addProperty("sourceHeight", sourceHeight);
|
||||
result.addProperty("imageBase64", Base64.getEncoder().encodeToString(bytes.toByteArray()));
|
||||
job.finish(result);
|
||||
} catch (Exception exception) { job.fail("encoding_failed", "Could not encode observer screenshot"); }
|
||||
}
|
||||
|
||||
private static String dimension(Minecraft client) { return client.level.dimension().identifier().toString(); }
|
||||
|
||||
private static boolean matchesPosition(Minecraft client, CaptureRequest request) {
|
||||
return client.player != null && client.level != null
|
||||
&& (request.dimension() == null || request.dimension().equals(dimension(client)))
|
||||
&& Math.abs(client.player.getX() - request.x()) <= 0.05
|
||||
&& Math.abs(client.player.getY() - request.y()) <= 0.05
|
||||
&& Math.abs(client.player.getZ() - request.z()) <= 0.05;
|
||||
}
|
||||
|
||||
private static boolean matchesView(Minecraft client, CaptureRequest request) {
|
||||
return client.gui.hud.isHidden() && client.options.getCameraType() == CameraType.FIRST_PERSON
|
||||
&& client.options.fov().get() == request.fov()
|
||||
&& Math.abs(Math.IEEEremainder(client.player.getYRot() - request.yaw(), 360)) <= 0.1
|
||||
&& Math.abs(client.player.getXRot() - request.pitch()) <= 0.1;
|
||||
}
|
||||
|
||||
private static boolean chunksLoaded(Minecraft client) {
|
||||
int cx = Math.floorDiv(client.player.blockPosition().getX(), 16);
|
||||
int cz = Math.floorDiv(client.player.blockPosition().getZ(), 16);
|
||||
for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++)
|
||||
if (client.level.getChunkSource().getChunk(cx + dx, cz + dz, ChunkStatus.FULL, false) == null) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void restore(Minecraft client, Job job) {
|
||||
SavedView saved = job.saved;
|
||||
if (saved == null) return;
|
||||
if (client.gui.hud.isHidden() != saved.hudHidden) client.gui.hud.toggle();
|
||||
client.options.fov().set(saved.fov);
|
||||
client.options.bobView().set(saved.bobView);
|
||||
client.options.fovEffectScale().set(saved.fovEffectScale);
|
||||
client.options.setCameraType(saved.cameraType);
|
||||
if (client.player == job.player) {
|
||||
client.player.setYRot(saved.yaw);
|
||||
client.player.setXRot(saved.pitch);
|
||||
client.player.setOldRot();
|
||||
}
|
||||
job.saved = null;
|
||||
}
|
||||
|
||||
private void stop(Minecraft client) {
|
||||
instance = null;
|
||||
Job job = active.getAndSet(null);
|
||||
if (job != null) { job.fail("client_stopping", "Camera client is stopping"); restore(client, job); }
|
||||
if (http != null) http.close();
|
||||
watchdog.shutdownNow();
|
||||
encoder.shutdown();
|
||||
}
|
||||
|
||||
private record SavedView(boolean hudHidden, int fov, boolean bobView, double fovEffectScale,
|
||||
CameraType cameraType, float yaw, float pitch) {}
|
||||
|
||||
private static final class Job {
|
||||
final String id = UUID.randomUUID().toString();
|
||||
final CaptureRequest request;
|
||||
final long createdNanos = System.nanoTime();
|
||||
volatile JsonObject result;
|
||||
volatile boolean done;
|
||||
boolean initialized, readbackStarted;
|
||||
int stableTicks, readyFrames;
|
||||
SavedView saved;
|
||||
LocalPlayer player;
|
||||
ClientLevel level;
|
||||
Job(CaptureRequest request) {
|
||||
this.request = request;
|
||||
result = new JsonObject();
|
||||
result.addProperty("status", "pending");
|
||||
result.addProperty("captureId", id);
|
||||
}
|
||||
synchronized void finish(JsonObject value) {
|
||||
if (done) return;
|
||||
value.addProperty("captureId", id);
|
||||
result = value;
|
||||
done = true;
|
||||
}
|
||||
void fail(String code, String message) { finish(CameraHttpServer.error(code, message)); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package dev.minecraftbuilder.camera;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** No Minecraft APIs are called by HTTP threads. Backend only queues work and reads cached results. */
|
||||
public final class CameraHttpServer implements AutoCloseable {
|
||||
public interface Backend {
|
||||
JsonObject health();
|
||||
JsonObject submit(CaptureRequest request);
|
||||
JsonObject poll(String captureId);
|
||||
}
|
||||
private final HttpServer server;
|
||||
private final ExecutorService executor = Executors.newFixedThreadPool(2, Thread.ofPlatform()
|
||||
.daemon(true).name("mcb-camera-http-", 0).factory());
|
||||
private final byte[] authorization;
|
||||
private final Backend backend;
|
||||
|
||||
public CameraHttpServer(int port, String token, Backend backend) throws IOException {
|
||||
if (token == null || token.length() < 32 || token.length() > 512 || token.chars().anyMatch(Character::isWhitespace))
|
||||
throw new IllegalArgumentException("MCB_CAMERA_TOKEN must contain 32..512 non-whitespace characters");
|
||||
this.authorization = ("Bearer " + token).getBytes(StandardCharsets.UTF_8);
|
||||
this.backend = backend;
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), port), 8);
|
||||
server.setExecutor(executor);
|
||||
server.createContext("/", this::handle);
|
||||
}
|
||||
|
||||
public void start() { server.start(); }
|
||||
public int port() { return server.getAddress().getPort(); }
|
||||
|
||||
private void handle(HttpExchange exchange) throws IOException {
|
||||
try (exchange) {
|
||||
String bearer = exchange.getRequestHeaders().getFirst("Authorization");
|
||||
if (bearer == null || !MessageDigest.isEqual(authorization, bearer.getBytes(StandardCharsets.UTF_8))) {
|
||||
send(exchange, 401, error("unauthorized", "Valid camera bearer token required"));
|
||||
return;
|
||||
}
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
String method = exchange.getRequestMethod();
|
||||
if (path.equals("/health") && method.equals("GET")) {
|
||||
send(exchange, 200, backend.health());
|
||||
} else if (path.equals("/v1/capture") && method.equals("POST")) {
|
||||
byte[] body = exchange.getRequestBody().readNBytes(8193);
|
||||
if (body.length > 8192) {
|
||||
send(exchange, 413, error("request_too_large", "Capture request exceeds 8192 bytes"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JsonObject result = backend.submit(CaptureRequest.parse(JsonParser.parseString(
|
||||
new String(body, StandardCharsets.UTF_8)).getAsJsonObject()));
|
||||
send(exchange, result.has("error") ? 409 : 202, result);
|
||||
} catch (RuntimeException exception) {
|
||||
send(exchange, 400, error("invalid_request", "Invalid capture JSON or capture parameters"));
|
||||
}
|
||||
} else if (path.startsWith("/v1/captures/") && method.equals("GET")) {
|
||||
String id = path.substring("/v1/captures/".length());
|
||||
if (!id.matches("[0-9a-f-]{36}")) {
|
||||
send(exchange, 400, error("invalid_capture_id", "Expected a capture UUID"));
|
||||
return;
|
||||
}
|
||||
JsonObject result = backend.poll(id);
|
||||
send(exchange, result == null ? 404 : 200,
|
||||
result == null ? error("capture_not_found", "Capture expired or does not exist") : result);
|
||||
} else {
|
||||
send(exchange, 404, error("not_found", "Unknown camera endpoint or method"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static JsonObject error(String code, String message) {
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("status", "error");
|
||||
result.addProperty("error", code);
|
||||
result.addProperty("message", message);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void send(HttpExchange exchange, int code, JsonObject result) throws IOException {
|
||||
byte[] bytes = result.toString().getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
|
||||
exchange.sendResponseHeaders(code, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
}
|
||||
|
||||
@Override public void close() { server.stop(0); executor.shutdownNow(); }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dev.minecraftbuilder.camera;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.Set;
|
||||
|
||||
/** Coordinates are the observer's feet, matching Paper teleports; the reply also gives eye position. */
|
||||
public record CaptureRequest(double x, double y, double z, float yaw, float pitch, int fov,
|
||||
int width, int height, String dimension, String afterOperationId) {
|
||||
private static final Set<String> FIELDS = Set.of("x", "y", "z", "yaw", "pitch", "fov", "width",
|
||||
"height", "dimension", "world", "world_id", "afterOperationId");
|
||||
|
||||
public static CaptureRequest parse(JsonObject body) {
|
||||
if (!FIELDS.containsAll(body.keySet())) throw new IllegalArgumentException("Unknown capture field");
|
||||
double x = number(body, "x"), y = number(body, "y"), z = number(body, "z");
|
||||
double yaw = number(body, "yaw"), pitch = number(body, "pitch");
|
||||
if (Math.abs(x) > 29_999_984 || Math.abs(z) > 29_999_984 || y < -2048 || y > 2048)
|
||||
throw new IllegalArgumentException("Position is outside the camera coordinate limits");
|
||||
if (Math.abs(yaw) > 360 || Math.abs(pitch) > 90)
|
||||
throw new IllegalArgumentException("yaw must be -360..360 and pitch -90..90");
|
||||
int fov = integer(body, "fov", 70, 30, 110);
|
||||
int width = integer(body, "width", 1280, 320, 1920);
|
||||
int height = integer(body, "height", 720, 180, 1080);
|
||||
return new CaptureRequest(x, y, z, (float) yaw, (float) pitch, fov, width, height,
|
||||
string(body, "dimension", 128), string(body, "afterOperationId", 128));
|
||||
}
|
||||
|
||||
private static double number(JsonObject body, String name) {
|
||||
if (!body.has(name) || !body.get(name).isJsonPrimitive() || !body.getAsJsonPrimitive(name).isNumber())
|
||||
throw new IllegalArgumentException(name + " must be a number");
|
||||
double value = body.get(name).getAsDouble();
|
||||
if (!Double.isFinite(value)) throw new IllegalArgumentException(name + " must be finite");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static int integer(JsonObject body, String name, int fallback, int min, int max) {
|
||||
if (!body.has(name)) return fallback;
|
||||
double value = number(body, name);
|
||||
if (value != Math.rint(value) || value < min || value > max)
|
||||
throw new IllegalArgumentException(name + " must be an integer in " + min + ".." + max);
|
||||
return (int) value;
|
||||
}
|
||||
|
||||
private static String string(JsonObject body, String name, int max) {
|
||||
if (!body.has(name)) return null;
|
||||
if (!body.get(name).isJsonPrimitive() || !body.getAsJsonPrimitive(name).isString())
|
||||
throw new IllegalArgumentException(name + " must be a string");
|
||||
String value = body.get(name).getAsString();
|
||||
if (value.isBlank() || value.length() > max) throw new IllegalArgumentException(name + " has invalid length");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.minecraftbuilder.camera.mixin;
|
||||
|
||||
import dev.minecraftbuilder.camera.CameraClient;
|
||||
import net.minecraft.client.DeltaTracker;
|
||||
import net.minecraft.client.renderer.GameRenderer;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(GameRenderer.class)
|
||||
abstract class GameRendererMixin {
|
||||
@Inject(method = "render", at = @At("TAIL"))
|
||||
private void mcb$afterFrame(DeltaTracker deltaTracker, boolean renderWorld, CallbackInfo callback) {
|
||||
CameraClient.afterRender(renderWorld);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "minecraft_builder_camera",
|
||||
"version": "${version}",
|
||||
"name": "Minecraft Builder Camera",
|
||||
"description": "Authenticated loopback camera worker for minecraft-builder-mcp.",
|
||||
"environment": "client",
|
||||
"entrypoints": { "client": ["dev.minecraftbuilder.camera.CameraClient"] },
|
||||
"mixins": ["minecraft-builder-camera.mixins.json"],
|
||||
"depends": {
|
||||
"fabricloader": ">=0.19.5",
|
||||
"minecraft": "26.2",
|
||||
"java": ">=25",
|
||||
"fabric-api": "0.160.0+26.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"required": true,
|
||||
"package": "dev.minecraftbuilder.camera.mixin",
|
||||
"compatibilityLevel": "JAVA_25",
|
||||
"client": ["GameRendererMixin"],
|
||||
"injectors": { "defaultRequire": 1 }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package dev.minecraftbuilder.camera;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CameraHttpServerTest {
|
||||
private static final String TOKEN = "test-camera-token-with-at-least-32-characters";
|
||||
private static final String VALID = "{\"x\":1,\"y\":64,\"z\":-2,\"yaw\":0,\"pitch\":15}";
|
||||
|
||||
@Test void authenticatesBeforeQueueingAndEnforcesRequestBudget() throws Exception {
|
||||
AtomicInteger queued = new AtomicInteger();
|
||||
CameraHttpServer.Backend backend = new CameraHttpServer.Backend() {
|
||||
public JsonObject health() { return JsonParser.parseString("{\"status\":\"ok\"}").getAsJsonObject(); }
|
||||
public JsonObject submit(CaptureRequest request) {
|
||||
queued.incrementAndGet();
|
||||
return JsonParser.parseString("{\"status\":\"pending\",\"captureId\":\"00000000-0000-0000-0000-000000000000\"}").getAsJsonObject();
|
||||
}
|
||||
public JsonObject poll(String id) { return null; }
|
||||
};
|
||||
try (CameraHttpServer server = new CameraHttpServer(0, TOKEN, backend); HttpClient client = HttpClient.newHttpClient()) {
|
||||
server.start();
|
||||
String base = "http://127.0.0.1:" + server.port();
|
||||
assertEquals(401, send(client, base + "/v1/capture", "POST", VALID, "wrong").statusCode());
|
||||
assertEquals(0, queued.get());
|
||||
assertEquals(202, send(client, base + "/v1/capture", "POST", VALID, TOKEN).statusCode());
|
||||
assertEquals(1, queued.get());
|
||||
assertEquals(413, send(client, base + "/v1/capture", "POST", " ".repeat(8193), TOKEN).statusCode());
|
||||
assertEquals(400, send(client, base + "/v1/capture", "POST", "{\"x\":true}", TOKEN).statusCode());
|
||||
assertEquals(1, queued.get());
|
||||
assertEquals(404, send(client, base + "/v1/captures/00000000-0000-0000-0000-000000000000", "GET", "", TOKEN).statusCode());
|
||||
assertEquals("no-store", send(client, base + "/health", "GET", "", TOKEN).headers().firstValue("Cache-Control").orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsTokenlessOrWeakService() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new CameraHttpServer(0, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new CameraHttpServer(0, "weak", null));
|
||||
}
|
||||
|
||||
private static HttpResponse<String> send(HttpClient client, String url, String method, String body, String token) throws Exception {
|
||||
return client.send(HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(5))
|
||||
.header("Authorization", "Bearer " + token).method(method, HttpRequest.BodyPublishers.ofString(body)).build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package dev.minecraftbuilder.camera;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CaptureRequestTest {
|
||||
private static final String POSE = "\"x\":1,\"y\":64,\"z\":-2,\"yaw\":0,\"pitch\":15";
|
||||
|
||||
@Test void suppliesBoundedDefaultsAndKeepsCorrelation() {
|
||||
CaptureRequest request = parse("{" + POSE + ",\"afterOperationId\":\"op-42\",\"dimension\":\"minecraft:overworld\"}");
|
||||
assertEquals(1280, request.width());
|
||||
assertEquals(720, request.height());
|
||||
assertEquals(70, request.fov());
|
||||
assertEquals("op-42", request.afterOperationId());
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"\"width\":1921", "\"width\":320.5", "\"height\":0",
|
||||
"\"fov\":111", "\"width\":\"640\"", "\"afterOperationId\":null", "\"shell\":\"noop\""})
|
||||
void rejectsUnsafeOrAmbiguousParameters(String extra) {
|
||||
assertThrows(IllegalArgumentException.class, () -> parse("{" + POSE + "," + extra + "}"));
|
||||
}
|
||||
|
||||
@Test void rejectsNonFiniteAndOutOfWorldPose() {
|
||||
assertThrows(IllegalArgumentException.class, () -> parse("{" + POSE.replace("\"x\":1", "\"x\":1e999") + "}"));
|
||||
assertThrows(IllegalArgumentException.class, () -> parse("{" + POSE.replace("\"pitch\":15", "\"pitch\":91") + "}"));
|
||||
assertThrows(IllegalArgumentException.class, () -> parse("{" + POSE.replace("\"y\":64", "\"y\":4096") + "}"));
|
||||
}
|
||||
|
||||
private static CaptureRequest parse(String input) { return CaptureRequest.parse(JsonParser.parseString(input).getAsJsonObject()); }
|
||||
}
|
||||
Reference in New Issue
Block a user