using System; using System.Globalization; using System.IO; using Newtonsoft.Json; using Unity.Pipeline.Commands; using UnityEditor; using UnityEngine; namespace Unity.Pipeline.Editor.Commands { /// /// Captures the Scene or Game view of a running Editor as a PNG and returns the file path. /// Backs the unity screenshot CLI command (CLI-112): the CLI only forwards the request /// via unity request screenshot — all capture logic lives here in the package, per the /// two-commands design. /// /// Capture renders the relevant view camera into an offscreen RenderTexture and reads it back, /// rather than using ScreenCapture.CaptureScreenshot. CaptureScreenshot only targets the Game /// view and writes asynchronously on the next frame — a single request/response can't reliably /// wait for that, and it does nothing for the Scene view in edit mode. Camera.Render() into a /// RenderTexture is synchronous, needs no play mode, and handles both views identically. /// public static class ScreenshotCommand { const int k_DefaultWidth = 1920; const int k_DefaultHeight = 1080; [CliCommand("screenshot", "Capture the Scene or Game view as a PNG and return its file path", MainThreadRequired = true)] public static ScreenshotResponse CaptureScreenshot( [CliArg("view", "Which view to capture: 'game' (default) or 'scene'")] string view = "game", [CliArg("output", "Output PNG path (absolute, or relative to the project root). Defaults to a timestamped file under /Temp/pipeline-screenshots/.")] string output = "", [CliArg("width", "Output width in pixels. 0 (default) uses the view camera's current width.")] int width = 0, [CliArg("height", "Output height in pixels. 0 (default) uses the view camera's current height.")] int height = 0) { var normalizedView = string.IsNullOrWhiteSpace(view) ? "game" : view.Trim().ToLowerInvariant(); if (normalizedView != "game" && normalizedView != "scene") return ScreenshotResponse.Fail($"Invalid view '{view}'. Expected 'game' or 'scene'."); if (width < 0 || height < 0) return ScreenshotResponse.Fail("width and height must be >= 0 (0 = use the view's current size)."); var camera = ResolveCamera(normalizedView, out var resolveError); if (camera == null) return ScreenshotResponse.Fail(resolveError); // Default the size to the camera's current pixel size, falling back to 1080p when the // camera reports nothing useful (can happen for an off-screen scene camera). var w = width > 0 ? width : camera.pixelWidth; var h = height > 0 ? height : camera.pixelHeight; if (w <= 1) w = k_DefaultWidth; if (h <= 1) h = k_DefaultHeight; var path = ResolveOutputPath(output, normalizedView); try { var png = RenderToPng(camera, w, h); var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); File.WriteAllBytes(path, png); } catch (Exception ex) { return ScreenshotResponse.Fail($"Failed to capture {normalizedView} view: {ex.Message}"); } return new ScreenshotResponse { Success = true, Path = path, View = normalizedView, Width = w, Height = h, Message = $"Captured {normalizedView} view to {path}" }; } /// /// Resolve the camera to render for the requested view, or null with an explanatory error. /// static Camera ResolveCamera(string view, out string error) { error = null; if (view == "scene") { var sv = SceneView.lastActiveSceneView; if (sv == null || sv.camera == null) { error = "No active Scene view to capture. Open a Scene view window and try again."; return null; } return sv.camera; } // Game view: prefer the main camera, otherwise the first enabled camera in the open scenes. var cam = Camera.main; if (cam == null && Camera.allCamerasCount > 0) { var all = Camera.allCameras; if (all.Length > 0) cam = all[0]; } if (cam == null) error = "No camera found to capture the Game view (no MainCamera and no enabled cameras in the open scenes)."; return cam; } /// /// Render into an offscreen RenderTexture at the given size and /// encode it to PNG. Restores the camera's previous target and the active RenderTexture so /// the capture leaves no side effects on the live editor. /// static byte[] RenderToPng(Camera camera, int width, int height) { var rt = new RenderTexture(width, height, 24); var prevTarget = camera.targetTexture; var prevActive = RenderTexture.active; Texture2D tex = null; try { camera.targetTexture = rt; camera.Render(); RenderTexture.active = rt; tex = new Texture2D(width, height, TextureFormat.RGB24, false); tex.ReadPixels(new Rect(0, 0, width, height), 0, 0); tex.Apply(); return tex.EncodeToPNG(); } finally { camera.targetTexture = prevTarget; RenderTexture.active = prevActive; rt.Release(); UnityEngine.Object.DestroyImmediate(rt); if (tex != null) UnityEngine.Object.DestroyImmediate(tex); } } /// /// Resolve the output path: an explicit path is used as-is (rooted) or relative to the /// project root; an empty path defaults to a timestamped file under Temp. /// static string ResolveOutputPath(string output, string view) { var projectRoot = Path.GetDirectoryName(Application.dataPath); if (!string.IsNullOrWhiteSpace(output)) { return Path.IsPathRooted(output) ? output : Path.GetFullPath(Path.Combine(projectRoot, output)); } var stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff", CultureInfo.InvariantCulture); return Path.Combine(projectRoot, "Temp", "pipeline-screenshots", $"screenshot_{view}_{stamp}.png"); } } /// /// Result of the screenshot command. Serialized to JSON for the CLI's --format json /// output; the human formatter reads . /// [Serializable] public class ScreenshotResponse { [JsonProperty("success")] public bool Success { get; set; } [JsonProperty("path")] public string Path { get; set; } [JsonProperty("view")] public string View { get; set; } [JsonProperty("width")] public int Width { get; set; } [JsonProperty("height")] public int Height { get; set; } [JsonProperty("message")] public string Message { get; set; } public static ScreenshotResponse Fail(string message) => new ScreenshotResponse { Success = false, Message = message }; } }