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
};
}
}