Skip to content

Commit fb9598d

Browse files
committed
Fix and GUI update
1 parent 21c73dd commit fb9598d

7 files changed

Lines changed: 389 additions & 19 deletions

File tree

MCPForUnity/Editor/Tools/Cameras/CameraControl.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -244,17 +244,17 @@ internal static object ForceCamera(JObject @params)
244244

245245
try
246246
{
247-
// ICinemachineCamera interface
248-
var icmInterface = cmCamera.GetType().GetInterface("ICinemachineCamera")
249-
?? cmCamera.GetType().GetInterface("Unity.Cinemachine.ICinemachineCamera");
250-
247+
// CM3 signature: SetCameraOverride(int overrideId, int priority,
248+
// ICinemachineCamera camA, ICinemachineCamera camB, float weightB, float deltaTime)
249+
// -1 for overrideId creates a new override; same cam for A+B with weight=1 = instant switch
251250
_overrideId = (int)method.Invoke(brain, new object[]
252251
{
253-
_overrideId >= 0 ? _overrideId : 0,
254-
cmCamera, // overrideCamera
255-
cmCamera, // fromCamera (blend from current)
256-
0f, // blend time = instant
257-
-1f // timeout = permanent
252+
_overrideId >= 0 ? _overrideId : -1,
253+
999, // high priority to win over all others
254+
cmCamera, // camA (at weight=0)
255+
cmCamera, // camB (at weight=1) — same camera = no blend
256+
1f, // weightB = fully on camB
257+
-1f // deltaTime = use default
258258
});
259259
}
260260
catch (Exception ex)

MCPForUnity/Editor/Tools/Cameras/ManageCamera.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ public static object HandleCommand(JObject @params)
5959

6060
case "list_cameras":
6161
return CameraControl.ListCameras(@params);
62+
63+
case "screenshot":
64+
case "screenshot_multiview":
65+
{
66+
// Delegate to ManageScene's screenshot infrastructure
67+
var shotParams = new JObject(@params);
68+
shotParams["action"] = "screenshot";
69+
if (action == "screenshot_multiview")
70+
{
71+
shotParams["batch"] = "surround";
72+
shotParams["includeImage"] = true;
73+
}
74+
return ManageScene.HandleCommand(shotParams);
75+
}
6276
}
6377

6478
// Tier 2: Cinemachine-only actions
@@ -104,7 +118,8 @@ public static object HandleCommand(JObject @params)
104118
default:
105119
return new ErrorResponse(
106120
$"Unknown action: '{action}'. Valid actions: ping, create_camera, set_target, "
107-
+ "set_lens, set_priority, list_cameras, ensure_brain, get_brain_status, "
121+
+ "set_lens, set_priority, list_cameras, screenshot, screenshot_multiview, "
122+
+ "ensure_brain, get_brain_status, "
108123
+ "set_body, set_aim, set_noise, add_extension, remove_extension, "
109124
+ "set_blend, force_camera, release_override.");
110125
}

MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ private VisualElement CreateToolRow(ToolMetadata tool)
319319
row.Add(parametersLabel);
320320
}
321321

322-
if (IsManageSceneTool(tool))
322+
if (IsManageSceneTool(tool) || IsManageCameraTool(tool))
323323
{
324324
row.Add(CreateManageSceneActions());
325325
}
@@ -734,6 +734,8 @@ private static Label CreateTag(string text)
734734

735735
private static bool IsManageSceneTool(ToolMetadata tool) => string.Equals(tool?.Name, "manage_scene", StringComparison.OrdinalIgnoreCase);
736736

737+
private static bool IsManageCameraTool(ToolMetadata tool) => string.Equals(tool?.Name, "manage_camera", StringComparison.OrdinalIgnoreCase);
738+
737739
private static bool IsBatchExecuteTool(ToolMetadata tool) => string.Equals(tool?.Name, "batch_execute", StringComparison.OrdinalIgnoreCase);
738740

739741
private static bool IsBuiltIn(ToolMetadata tool) => tool?.IsBuiltIn ?? false;

Server/src/cli/commands/camera.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,3 +469,68 @@ def release_override():
469469
config = get_config()
470470
result = run_command(config, "manage_camera", {"action": "release_override"})
471471
format_output(result, config)
472+
473+
474+
# =============================================================================
475+
# Capture
476+
# =============================================================================
477+
478+
@camera.command("screenshot")
479+
@click.option("--camera-ref", default=None, help="Camera to capture from (name/path/ID).")
480+
@click.option("--file-name", default=None, help="Output file name.")
481+
@click.option("--super-size", type=int, default=None, help="Supersize multiplier.")
482+
@click.option("--include-image/--no-include-image", default=None, help="Return inline base64 PNG.")
483+
@click.option("--max-resolution", type=int, default=None, help="Max resolution for inline image.")
484+
@click.option("--batch", default=None, type=click.Choice(["surround", "orbit"]),
485+
help="Batch capture mode.")
486+
@click.option("--look-at", default=None, help="Target to aim at (name/path/ID or [x,y,z]).")
487+
@handle_unity_errors
488+
def screenshot(camera_ref, file_name, super_size, include_image, max_resolution, batch, look_at):
489+
"""Capture a screenshot from a camera.
490+
491+
\b
492+
Examples:
493+
unity-mcp camera screenshot
494+
unity-mcp camera screenshot --camera-ref "CM FollowCam" --include-image --max-resolution 512
495+
unity-mcp camera screenshot --batch surround --look-at Player
496+
"""
497+
config = get_config()
498+
params: dict[str, Any] = {"action": "screenshot"}
499+
if camera_ref:
500+
params["camera"] = camera_ref
501+
if file_name:
502+
params["fileName"] = file_name
503+
if super_size is not None:
504+
params["superSize"] = super_size
505+
if include_image is not None:
506+
params["includeImage"] = include_image
507+
if max_resolution is not None:
508+
params["maxResolution"] = max_resolution
509+
if batch:
510+
params["batch"] = batch
511+
if look_at:
512+
params["lookAt"] = look_at
513+
result = run_command(config, "manage_camera", params)
514+
format_output(result, config)
515+
516+
517+
@camera.command("screenshot-multiview")
518+
@click.option("--max-resolution", type=int, default=None, help="Max resolution per tile.")
519+
@click.option("--look-at", default=None, help="Center target for the multiview capture.")
520+
@handle_unity_errors
521+
def screenshot_multiview(max_resolution, look_at):
522+
"""Capture a 6-angle contact sheet around the scene.
523+
524+
\b
525+
Examples:
526+
unity-mcp camera screenshot-multiview
527+
unity-mcp camera screenshot-multiview --look-at Player --max-resolution 480
528+
"""
529+
config = get_config()
530+
params: dict[str, Any] = {"action": "screenshot_multiview"}
531+
if max_resolution is not None:
532+
params["maxResolution"] = max_resolution
533+
if look_at:
534+
params["lookAt"] = look_at
535+
result = run_command(config, "manage_camera", params)
536+
format_output(result, config)

Server/src/services/tools/manage_camera.py

Lines changed: 158 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import json
12
from typing import Annotated, Any, Literal
23

34
from fastmcp import Context
4-
from mcp.types import ToolAnnotations
5+
from fastmcp.server.server import ToolResult
6+
from mcp.types import ToolAnnotations, TextContent, ImageContent
57

68
from services.registry import mcp_for_unity_tool
79
from services.tools import get_unity_instance_from_context
10+
from services.tools.utils import coerce_int, coerce_bool, normalize_vector3
811
from transport.unity_transport import send_with_unity_instance
912
from transport.legacy.unity_connection import async_send_command_with_retry
1013

@@ -24,7 +27,57 @@
2427
"set_blend", "force_camera", "release_override", "list_cameras",
2528
]
2629

27-
ALL_ACTIONS = SETUP_ACTIONS + CREATION_ACTIONS + CONFIGURATION_ACTIONS + EXTENSION_ACTIONS + CONTROL_ACTIONS
30+
CAPTURE_ACTIONS = ["screenshot", "screenshot_multiview"]
31+
32+
ALL_ACTIONS = SETUP_ACTIONS + CREATION_ACTIONS + CONFIGURATION_ACTIONS + EXTENSION_ACTIONS + CONTROL_ACTIONS + CAPTURE_ACTIONS
33+
34+
35+
def _extract_images(response: dict[str, Any], action: str) -> ToolResult | None:
36+
"""If the Unity response contains inline base64 images, return a ToolResult
37+
with TextContent + ImageContent blocks. Returns None for normal text-only responses."""
38+
if not isinstance(response, dict) or not response.get("success"):
39+
return None
40+
41+
data = response.get("data")
42+
if not isinstance(data, dict):
43+
return None
44+
45+
# Batch images (surround/orbit mode) — multiple screenshots in one response
46+
screenshots = data.get("screenshots")
47+
if screenshots and isinstance(screenshots, list):
48+
blocks: list[TextContent | ImageContent] = []
49+
summary_screenshots = []
50+
for s in screenshots:
51+
summary_screenshots.append({k: v for k, v in s.items() if k != "imageBase64"})
52+
text_result = {
53+
"success": True,
54+
"message": response.get("message", ""),
55+
"data": {
56+
"sceneCenter": data.get("sceneCenter"),
57+
"sceneRadius": data.get("sceneRadius"),
58+
"screenshots": summary_screenshots,
59+
},
60+
}
61+
blocks.append(TextContent(type="text", text=json.dumps(text_result)))
62+
for s in screenshots:
63+
b64 = s.get("imageBase64")
64+
if b64:
65+
blocks.append(TextContent(type="text", text=f"[Angle: {s.get('angle', '?')}]"))
66+
blocks.append(ImageContent(type="image", data=b64, mimeType="image/png"))
67+
return ToolResult(content=blocks)
68+
69+
# Single image (include_image or positioned capture) or contact sheet
70+
image_b64 = data.get("imageBase64")
71+
if not image_b64:
72+
return None
73+
text_data = {k: v for k, v in data.items() if k != "imageBase64"}
74+
text_result = {"success": True, "message": response.get("message", ""), "data": text_data}
75+
return ToolResult(
76+
content=[
77+
TextContent(type="text", text=json.dumps(text_result)),
78+
ImageContent(type="image", data=image_b64, mimeType="image/png"),
79+
],
80+
)
2881

2982

3083
@mcp_for_unity_tool(
@@ -55,7 +108,12 @@
55108
"- set_blend: Configure default blend (style: Cut/EaseInOut/Linear/etc., duration)\n"
56109
"- force_camera: Override Brain to use specific camera\n"
57110
"- release_override: Release camera override\n"
58-
"- list_cameras: List all cameras with status\n"
111+
"- list_cameras: List all cameras with status\n\n"
112+
"CAPTURE:\n"
113+
"- screenshot: Capture from a camera. Supports include_image=true for inline base64 PNG, "
114+
"batch='surround' for 6-angle contact sheet, batch='orbit' for configurable grid, "
115+
"look_at/view_position for positioned capture.\n"
116+
"- screenshot_multiview: Shorthand for screenshot with batch='surround' and include_image=true."
59117
),
60118
annotations=ToolAnnotations(
61119
title="Manage Camera",
@@ -74,7 +132,34 @@ async def manage_camera(
74132
dict[str, Any] | str | None,
75133
"Action-specific parameters (dict or JSON string).",
76134
] = None,
77-
) -> dict[str, Any]:
135+
# --- screenshot params ---
136+
screenshot_file_name: Annotated[str | None,
137+
"Screenshot file name (optional). Defaults to timestamp."] = None,
138+
screenshot_super_size: Annotated[int | str | None,
139+
"Screenshot supersize multiplier (integer >= 1)."] = None,
140+
camera: Annotated[str | None,
141+
"Camera to capture from (name, path, or instance ID). Defaults to Camera.main."] = None,
142+
include_image: Annotated[bool | str | None,
143+
"If true, return screenshot as inline base64 PNG. Default false."] = None,
144+
max_resolution: Annotated[int | str | None,
145+
"Max resolution (longest edge px) for inline image. Default 640."] = None,
146+
batch: Annotated[str | None,
147+
"Batch capture mode: 'surround' (6 angles) or 'orbit' (configurable grid)."] = None,
148+
look_at: Annotated[str | int | list[float] | None,
149+
"Target to aim camera at. GameObject name/path/ID or [x,y,z]."] = None,
150+
view_position: Annotated[list[float] | str | None,
151+
"World position [x,y,z] to place camera for positioned capture."] = None,
152+
view_rotation: Annotated[list[float] | str | None,
153+
"Euler rotation [x,y,z] for camera. Overrides look_at if both provided."] = None,
154+
orbit_angles: Annotated[int | str | None,
155+
"Number of azimuth samples for batch='orbit' (default 8, max 36)."] = None,
156+
orbit_elevations: Annotated[list[float] | str | None,
157+
"Elevation angles in degrees for batch='orbit' (default [0, 30, -15])."] = None,
158+
orbit_distance: Annotated[float | str | None,
159+
"Camera distance from target for batch='orbit' (default auto)."] = None,
160+
orbit_fov: Annotated[float | str | None,
161+
"Camera FOV in degrees for batch='orbit' (default 60)."] = None,
162+
) -> dict[str, Any] | ToolResult:
78163
"""Unified camera management tool (Unity Camera + Cinemachine)."""
79164

80165
action_normalized = action.lower()
@@ -86,6 +171,7 @@ async def manage_camera(
86171
"Configuration": CONFIGURATION_ACTIONS,
87172
"Extensions": EXTENSION_ACTIONS,
88173
"Control": CONTROL_ACTIONS,
174+
"Capture": CAPTURE_ACTIONS,
89175
}
90176
category_list = "; ".join(
91177
f"{cat}: {', '.join(actions)}" for cat, actions in categories.items()
@@ -108,6 +194,64 @@ async def manage_camera(
108194
if search_method is not None:
109195
params_dict["searchMethod"] = search_method
110196

197+
# Screenshot params — only relevant for screenshot/screenshot_multiview actions
198+
if action_normalized in CAPTURE_ACTIONS:
199+
if screenshot_file_name:
200+
params_dict["fileName"] = screenshot_file_name
201+
coerced_super_size = coerce_int(screenshot_super_size, default=None)
202+
if coerced_super_size is not None:
203+
params_dict["superSize"] = coerced_super_size
204+
if camera:
205+
params_dict["camera"] = camera
206+
coerced_include_image = coerce_bool(include_image, default=None)
207+
if coerced_include_image is not None:
208+
params_dict["includeImage"] = coerced_include_image
209+
coerced_max_resolution = coerce_int(max_resolution, default=None)
210+
if coerced_max_resolution is not None:
211+
if coerced_max_resolution <= 0:
212+
return {"success": False, "message": "max_resolution must be a positive integer."}
213+
params_dict["maxResolution"] = coerced_max_resolution
214+
if batch:
215+
params_dict["batch"] = batch
216+
if look_at is not None:
217+
params_dict["lookAt"] = look_at
218+
219+
# Orbit params
220+
coerced_orbit_angles = coerce_int(orbit_angles, default=None)
221+
if coerced_orbit_angles is not None:
222+
params_dict["orbitAngles"] = coerced_orbit_angles
223+
if orbit_elevations is not None:
224+
if isinstance(orbit_elevations, str):
225+
try:
226+
orbit_elevations = json.loads(orbit_elevations)
227+
except (ValueError, TypeError):
228+
return {"success": False, "message": "orbit_elevations must be a JSON array of floats."}
229+
if not isinstance(orbit_elevations, list) or not all(
230+
isinstance(v, (int, float)) for v in orbit_elevations
231+
):
232+
return {"success": False, "message": "orbit_elevations must be a list of numbers."}
233+
params_dict["orbitElevations"] = orbit_elevations
234+
if orbit_distance is not None:
235+
try:
236+
params_dict["orbitDistance"] = float(orbit_distance)
237+
except (ValueError, TypeError):
238+
return {"success": False, "message": "orbit_distance must be a number."}
239+
if orbit_fov is not None:
240+
try:
241+
params_dict["orbitFov"] = float(orbit_fov)
242+
except (ValueError, TypeError):
243+
return {"success": False, "message": "orbit_fov must be a number."}
244+
if view_position is not None:
245+
vec, err = normalize_vector3(view_position, "view_position")
246+
if err:
247+
return {"success": False, "message": err}
248+
params_dict["viewPosition"] = vec
249+
if view_rotation is not None:
250+
vec, err = normalize_vector3(view_rotation, "view_rotation")
251+
if err:
252+
return {"success": False, "message": err}
253+
params_dict["viewRotation"] = vec
254+
111255
params_dict = {k: v for k, v in params_dict.items() if v is not None}
112256

113257
result = await send_with_unity_instance(
@@ -117,4 +261,13 @@ async def manage_camera(
117261
params_dict,
118262
)
119263

120-
return result if isinstance(result, dict) else {"success": False, "message": str(result)}
264+
if not isinstance(result, dict):
265+
return {"success": False, "message": str(result)}
266+
267+
# For capture actions, check for inline images to return as ImageContent
268+
if action_normalized in CAPTURE_ACTIONS:
269+
image_result = _extract_images(result, "screenshot")
270+
if image_result is not None:
271+
return image_result
272+
273+
return result

0 commit comments

Comments
 (0)