feat(gameplay): 更新单位属性和材质配置 - 调整多个单位的基础生命值和攻击力数值平衡 - 修改CoreBattleGround材质的纹理缩放比例 - 更新地砖材质颜色为白色 - 优化纹理资源设置,调整最大纹理尺寸并添加PlayableAds平台支持 - 修复弓箭手技能目标查找逻辑,改用统一的敌人检测方法 - 为水晶矿添加中立阵营设置和光球视觉效果同步 - 调整预制体中的全局缩放参数 - 为狼人和石巨人添加NavMeshAgent组件支持 - 优化水晶矿血条显示位置计算方式 ```
4129 lines
184 KiB
C#
4129 lines
184 KiB
C#
using System;
|
||
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using Codely.Newtonsoft.Json;
|
||
using System.Reflection;
|
||
using Codely.Newtonsoft.Json.Linq;
|
||
using UnityEditor;
|
||
using UnityEditorInternal;
|
||
using UnityEngine;
|
||
using UnityTcp.Editor.Helpers;
|
||
|
||
#if UNITY_2021_2_OR_NEWER
|
||
using UnityEngine.UIElements;
|
||
#endif
|
||
|
||
namespace UnityTcp.Editor.Tools
|
||
{
|
||
/// <summary>
|
||
/// Handles screenshot capture operations within Unity Editor.
|
||
///
|
||
/// Actions:
|
||
/// capture_game_view – Disabled legacy entry retained for a clear migration error.
|
||
/// capture_scene_view – Capture the Scene View from one or more directions.
|
||
/// capture_main_camera – Render Camera.main to a texture.
|
||
/// capture_specific_camera – Render a named camera to a texture.
|
||
/// capture_asset – Instantiate asset(s) in the current scene and render a preview.
|
||
/// capture_ui_toolkit – Render a UIDocument (.uxml) to a texture.
|
||
/// start_game_view_recording – Start a background Game View MP4 recording session.
|
||
/// finish_game_view_recording – Wait for and return the active MP4 recording result.
|
||
///
|
||
/// Legacy aliases:
|
||
/// capture → disabled legacy capture_game_view entry
|
||
/// capture_scene_camera → capture_scene_view
|
||
///
|
||
/// Common parameters (most actions):
|
||
/// path (string) – Custom save directory (default: <ProjectRoot>/screenshots).
|
||
/// filename (string) – Custom filename (with or without extension).
|
||
/// allow_static_in_play_mode (bool) – Explicit override for genuinely static Play Mode
|
||
/// camera output. Requires static_reason. Without this override,
|
||
/// capture_main_camera / capture_specific_camera are rejected in
|
||
/// Play Mode.
|
||
/// static_reason (string) – Why a single frame is sufficient for Play Mode verification.
|
||
/// over_time (string) – "N" or "NxS": capture N frames with S simulation steps
|
||
/// between each.
|
||
/// Applies to: capture_scene_view, capture_main_camera,
|
||
/// capture_specific_camera.
|
||
/// Ignored when as_gif is given (GIF capture takes precedence).
|
||
/// as_gif – When present (truthy, or an object holding the sub-params
|
||
/// below) the capture records multiple frames and saves them as
|
||
/// an animated .gif instead of a PNG. Takes precedence over
|
||
/// over_time. Supported by capture_scene_view, capture_main_camera,
|
||
/// capture_specific_camera.
|
||
/// Sub-parameters (accepted as siblings of as_gif or nested in it):
|
||
/// frameCount (int, 1-200) – REQUIRED. Number of frames to capture.
|
||
/// fps (int, 10-30) – Playback frames per second (default 25).
|
||
/// colorCount (int, 64-256) – GIF palette color count (default 128).
|
||
/// startDelay (float, 0-5) – Seconds to wait before capture starts
|
||
/// (default 0).
|
||
/// start_game_view_recording:
|
||
/// durationSeconds (float, 1-15) – Recording duration in real-time seconds (default 4).
|
||
/// fps (int, 5-30) – Capture frame rate: how many frames per second are
|
||
/// grabbed from the Game View (default 15). Higher values
|
||
/// capture more visual detail.
|
||
/// encodeFps (int, 1-30) – MP4 metadata frame rate (default 2). This controls how
|
||
/// the model samples the video. When encodeFps < fps, the MP4
|
||
/// plays in slow motion so a 2fps-sampling VLM (e.g. GLM) sees
|
||
/// every captured frame instead of skipping most of them.
|
||
/// Example: 5s @ capture 15fps + encode 2fps → 75 frames in
|
||
/// a 37.5s slow-motion MP4; a 2fps model samples all 75.
|
||
/// startDelay (float, 0-5) – Pre-roll before frames are recorded (default 0).
|
||
/// scale (float, 0.25-1.0) – Game View resolution scale (default 0.5).
|
||
/// Windows uses the Unity Editor MediaEncoder backed by the native platform media stack.
|
||
/// No external encoder executable is required.
|
||
/// finish_game_view_recording:
|
||
/// recording_id (string) – Required ID returned by start_game_view_recording.
|
||
///
|
||
/// capture_scene_view / capture_asset:
|
||
/// width (int) – Output width in pixels (default: 1024).
|
||
/// height (int) – Output height in pixels (default: 1024).
|
||
/// view (string) – Direction(s) to render. Applies to both capture_scene_view and
|
||
/// capture_asset. "current" (default for capture_scene_view) = WYSIWYG
|
||
/// editor view; "cardinal" (default for capture_asset) = front + right
|
||
/// + top + iso; "all" = all 7 directions; any named direction renders
|
||
/// a single view. Multi-view captures are stitched into a grid image.
|
||
/// orthographic (bool) – Use orthographic projection instead of perspective (default false).
|
||
/// Applies to both capture_scene_view and capture_asset.
|
||
/// render_mode (string) – "shaded" (default), "wireframe", or "shadedwireframe".
|
||
/// Controls how geometry is drawn. Only applies to capture_scene_view.
|
||
/// Non-shaded modes use the GL.wireframe renderer path (gizmos and
|
||
/// scene grid are not composited in those modes).
|
||
///
|
||
/// capture_main_camera / capture_specific_camera:
|
||
/// Always renders from the camera's current position, rotation and projection.
|
||
/// width (int) – Output width in pixels (default: camera pixel width).
|
||
/// height (int) – Output height in pixels (default: camera pixel height).
|
||
/// Note: view and orthographic parameters are not applicable to camera captures.
|
||
///
|
||
/// Auto-scaling:
|
||
/// When neither width nor height is supplied, each individual screenshot is
|
||
/// downsampled so its longest edge equals 256 pixels (aspect ratio preserved).
|
||
/// Supplying either width or height disables this behavior.
|
||
/// </summary>
|
||
public static class ManageScreenshot
|
||
{
|
||
private static readonly List<string> ValidActions = new List<string>
|
||
{
|
||
"capture_game_view",
|
||
"capture_scene_view",
|
||
"capture_main_camera",
|
||
"capture_specific_camera",
|
||
"capture_asset",
|
||
"capture_ui_toolkit",
|
||
"start_game_view_recording",
|
||
"finish_game_view_recording",
|
||
// Legacy aliases
|
||
"capture",
|
||
"capture_scene_camera",
|
||
};
|
||
|
||
// ======================== sync entry point (over_time forced to 1) ========================
|
||
|
||
/// <summary>
|
||
/// Command entry point. Starts the capture coroutine under the CoroutineRunner (capture may
|
||
/// span multiple editor frames) and returns the job — the response is delivered later when
|
||
/// the coroutine finishes.
|
||
/// </summary>
|
||
public static object HandleCommand(JObject @params)
|
||
{
|
||
var ctx = CoroutineRunner.CreateJob(CommandContext.RequestId, CommandContext.CommandType);
|
||
CoroutineRunner.RunJob(ctx, HandleCommandCoroutine(@params, r => ctx.SetResult(r)));
|
||
return ctx;
|
||
}
|
||
|
||
// ======================== coroutine entry point ========================
|
||
|
||
public static IEnumerator HandleCommandCoroutine(JObject @params, Action<object> setResult)
|
||
{
|
||
if (@params == null)
|
||
{
|
||
setResult(Response.Error("Parameters cannot be null."));
|
||
yield break;
|
||
}
|
||
|
||
if (!ActionRouter.TryResolve(@params, ValidActions, out var action, out var resolveError))
|
||
{
|
||
setResult(resolveError);
|
||
yield break;
|
||
}
|
||
|
||
object captureResult = null;
|
||
IEnumerator inner = null;
|
||
Exception caughtEx = null;
|
||
|
||
try
|
||
{
|
||
switch (action)
|
||
{
|
||
case "capture_game_view":
|
||
case "capture":
|
||
captureResult = Response.Error(
|
||
$"'{action}' is disabled. Use record_game_view on exec_runtime_script " +
|
||
"(or legacy execute_csharp_script) " +
|
||
"for dynamic Play Mode verification.");
|
||
break;
|
||
|
||
case "capture_scene_view":
|
||
case "capture_scene_camera":
|
||
inner = CaptureSceneViewCoroutine(@params, r => captureResult = r);
|
||
break;
|
||
|
||
case "capture_main_camera":
|
||
{
|
||
Camera cam = Camera.main;
|
||
if (cam == null) { captureResult = Response.Error("No main camera (Camera.main) found in the scene."); break; }
|
||
inner = CaptureFromCameraCoroutine(cam, @params, "MainCamera", r => captureResult = r);
|
||
break;
|
||
}
|
||
|
||
case "capture_specific_camera":
|
||
{
|
||
string camName = @params["camera_name"]?.ToString();
|
||
if (string.IsNullOrEmpty(camName)) { captureResult = Response.Error("'camera_name' parameter is required."); break; }
|
||
Camera cam = FindCameraByName(camName);
|
||
if (cam == null) { captureResult = Response.Error($"Camera '{camName}' not found in the scene."); break; }
|
||
inner = CaptureFromCameraCoroutine(cam, @params, camName, r => captureResult = r);
|
||
break;
|
||
}
|
||
|
||
case "capture_asset":
|
||
captureResult = CaptureAsset(@params);
|
||
break;
|
||
|
||
case "capture_ui_toolkit":
|
||
inner = CaptureUIToolkitCoroutine(@params, r => captureResult = r);
|
||
break;
|
||
|
||
case "start_game_view_recording":
|
||
captureResult = StartGameViewMp4Recording(@params);
|
||
break;
|
||
|
||
case "finish_game_view_recording":
|
||
inner = FinishGameViewMp4RecordingCoroutine(@params, r => captureResult = r);
|
||
break;
|
||
|
||
default:
|
||
captureResult = Response.Error($"Unknown action: '{action}'.");
|
||
break;
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
CodelyLogger.LogError($"[ManageScreenshot] Action '{action}' failed: {e}");
|
||
setResult(Response.Error($"Internal error processing action '{action}': {e.Message}"));
|
||
yield break;
|
||
}
|
||
|
||
if (inner != null)
|
||
{
|
||
while (true)
|
||
{
|
||
bool hasNext;
|
||
try { hasNext = inner.MoveNext(); }
|
||
catch (Exception e) { caughtEx = e; break; }
|
||
if (!hasNext) break;
|
||
yield return inner.Current;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
yield return null;
|
||
}
|
||
|
||
if (caughtEx != null)
|
||
{
|
||
CodelyLogger.LogError($"[ManageScreenshot] Action '{action}' failed: {caughtEx}");
|
||
setResult(Response.Error($"Internal error: {caughtEx.Message}"));
|
||
}
|
||
else
|
||
{
|
||
setResult(captureResult ?? Response.Error("No result produced."));
|
||
}
|
||
}
|
||
|
||
// ==================== Background Game View MP4 recording ====================
|
||
//
|
||
// The start/finish split lets callers start recording, trigger a short-lived runtime
|
||
// effect with another tool call, then wait for the recording result. A single blocking
|
||
// capture command cannot reliably observe short-lived effects because they may finish
|
||
// before the screenshot command begins.
|
||
|
||
internal enum Mp4RecordingOwner
|
||
{
|
||
Split,
|
||
ExecuteCSharp,
|
||
}
|
||
|
||
internal enum Mp4RecordingStatus
|
||
{
|
||
Pending,
|
||
Finished,
|
||
Unavailable,
|
||
}
|
||
|
||
private sealed class Mp4RecordingSession
|
||
{
|
||
public string Id;
|
||
public Mp4RecordingOwner Owner;
|
||
public string OwnerCommandName;
|
||
public int Waiters;
|
||
public string OutputPath;
|
||
public string EncoderPath;
|
||
public int Fps;
|
||
public int EncodeFps;
|
||
public int TargetFrames;
|
||
public int AttemptedFrames;
|
||
public int CapturedFrames;
|
||
public int DroppedFrames;
|
||
public int Width;
|
||
public int Height;
|
||
public float Scale;
|
||
public double NextCaptureTime;
|
||
public double ActualStartTime;
|
||
public double ActualEndTime;
|
||
public volatile bool Finished;
|
||
public object Result;
|
||
public string ResultJson;
|
||
public float StartDelay;
|
||
public float DurationSeconds;
|
||
public IMp4FrameEncoder Encoder;
|
||
}
|
||
|
||
internal static Func<string, int, int, int, IMp4FrameEncoder> Mp4EncoderFactoryOverride;
|
||
|
||
internal sealed class Mp4RecordingHandle
|
||
{
|
||
internal string Id;
|
||
internal string OutputPath;
|
||
internal int Fps;
|
||
internal int EncodeFps;
|
||
internal int TargetFrames;
|
||
internal float DurationSeconds;
|
||
internal float Scale;
|
||
}
|
||
|
||
private static Mp4RecordingSession s_Mp4Recording;
|
||
private static bool s_TickingMp4;
|
||
private static string s_LastCollectedRecordingId;
|
||
private static object s_LastCollectedRecordingResult;
|
||
|
||
internal static bool HasGameViewMp4Recording =>
|
||
s_Mp4Recording != null;
|
||
|
||
internal static bool HasSplitGameViewMp4Recording =>
|
||
s_Mp4Recording != null &&
|
||
s_Mp4Recording.Owner == Mp4RecordingOwner.Split;
|
||
|
||
internal static bool CanAttachExecuteCSharpGameViewRecording() =>
|
||
s_Mp4Recording != null &&
|
||
s_Mp4Recording.Owner == Mp4RecordingOwner.ExecuteCSharp;
|
||
|
||
private static object StartGameViewMp4Recording(JObject p)
|
||
{
|
||
if (!TryBeginGameViewMp4Recording(p, out Mp4RecordingHandle handle, out object error))
|
||
return error;
|
||
|
||
return Response.Success("Game View MP4 recording started.", new
|
||
{
|
||
recording_id = handle.Id,
|
||
durationSeconds = handle.DurationSeconds,
|
||
fps = handle.Fps,
|
||
encodeFps = handle.EncodeFps,
|
||
targetFrames = handle.TargetFrames,
|
||
scale = handle.Scale,
|
||
path = handle.OutputPath,
|
||
});
|
||
}
|
||
|
||
internal static bool TryBeginOrAttachExecuteCSharpGameViewRecording(
|
||
JObject p, out Mp4RecordingHandle handle, out bool attached, out object error)
|
||
{
|
||
handle = null;
|
||
attached = false;
|
||
error = null;
|
||
|
||
if (s_Mp4Recording != null)
|
||
{
|
||
if (s_Mp4Recording.Owner != Mp4RecordingOwner.ExecuteCSharp)
|
||
{
|
||
TryGetActiveGameViewMp4RecordingConflict(out error);
|
||
return false;
|
||
}
|
||
|
||
s_Mp4Recording.Waiters++;
|
||
handle = ToHandle(s_Mp4Recording);
|
||
attached = true;
|
||
return true;
|
||
}
|
||
|
||
if (!TryBeginOwnedGameViewMp4Recording(
|
||
p, Mp4RecordingOwner.ExecuteCSharp, out handle, out error))
|
||
return false;
|
||
|
||
return true;
|
||
}
|
||
|
||
internal static bool TryBeginGameViewMp4Recording(
|
||
JObject p, out Mp4RecordingHandle handle, out object error)
|
||
{
|
||
return TryBeginOwnedGameViewMp4Recording(
|
||
p, Mp4RecordingOwner.Split, out handle, out error);
|
||
}
|
||
|
||
private static bool TryBeginOwnedGameViewMp4Recording(
|
||
JObject p, Mp4RecordingOwner owner, out Mp4RecordingHandle handle, out object error)
|
||
{
|
||
handle = null;
|
||
error = null;
|
||
|
||
if (TryGetActiveGameViewMp4RecordingConflict(out error))
|
||
return false;
|
||
|
||
if (!EditorApplication.isPlaying)
|
||
{
|
||
error = Response.Error(
|
||
"Game View MP4 recording requires Play Mode. " +
|
||
"Use exec_runtime_script with record_game_view " +
|
||
"(or enter play first and use legacy execute_csharp_script) " +
|
||
"and trigger or restart the effect in that script. " +
|
||
"Use start_game_view_recording only for input or external events " +
|
||
"that cannot be restarted from a script.");
|
||
return false;
|
||
}
|
||
|
||
if (!PlatformMp4Encoder.IsSupported && Mp4EncoderFactoryOverride == null)
|
||
{
|
||
error = Response.Error(PlatformMp4Encoder.UnsupportedMessage);
|
||
return false;
|
||
}
|
||
|
||
float duration = Mathf.Clamp(
|
||
AsFloat(p?["durationSeconds"] ?? p?["duration_seconds"]) ?? 4f, 1f, 15f);
|
||
int fps = Mathf.Clamp(AsInt(p?["fps"]) ?? 15, 5, 30);
|
||
int encodeFps = Mathf.Clamp(AsInt(p?["encodeFps"] ?? p?["encode_fps"]) ?? fps, 1, 30);
|
||
float startDelay = Mathf.Clamp(
|
||
AsFloat(p?["startDelay"] ?? p?["start_delay"]) ?? 0f, 0f, 5f);
|
||
float scale = Mathf.Clamp(AsFloat(p?["scale"]) ?? 0.5f, 0.25f, 1f);
|
||
|
||
if (!TryResolveMp4OutputDirectory(
|
||
p?["path"]?.ToString(), out string outputDirectory, out string pathError))
|
||
{
|
||
error = Response.Error(pathError);
|
||
return false;
|
||
}
|
||
|
||
string filename = p?["filename"]?.ToString();
|
||
if (string.IsNullOrEmpty(filename))
|
||
filename = $"GameView_{Timestamp()}.mp4";
|
||
else
|
||
{
|
||
if (Path.IsPathRooted(filename) ||
|
||
!string.Equals(filename, Path.GetFileName(filename), StringComparison.Ordinal) ||
|
||
filename.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||
{
|
||
error = Response.Error(
|
||
"'filename' must be a file name only, without a directory, absolute path, or '..'.");
|
||
return false;
|
||
}
|
||
|
||
if (!filename.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase))
|
||
filename += ".mp4";
|
||
}
|
||
|
||
try
|
||
{
|
||
Directory.CreateDirectory(outputDirectory);
|
||
string id = Guid.NewGuid().ToString("N");
|
||
string outputPath = Path.Combine(outputDirectory, filename);
|
||
if (File.Exists(outputPath))
|
||
File.Delete(outputPath);
|
||
string encoderPath = ResolveMp4EncoderPath(outputPath);
|
||
|
||
double now = EditorApplication.timeSinceStartup;
|
||
s_Mp4Recording = new Mp4RecordingSession
|
||
{
|
||
Id = id,
|
||
Owner = owner,
|
||
OwnerCommandName = owner == Mp4RecordingOwner.ExecuteCSharp
|
||
? ExecuteCSharpScript.DisplayCommandName
|
||
: null,
|
||
Waiters = 1,
|
||
OutputPath = outputPath,
|
||
EncoderPath = encoderPath,
|
||
Fps = fps,
|
||
EncodeFps = encodeFps,
|
||
TargetFrames = Mathf.Clamp(Mathf.CeilToInt(duration * fps), 1, 450),
|
||
Scale = scale,
|
||
StartDelay = startDelay,
|
||
DurationSeconds = duration,
|
||
NextCaptureTime = now + startDelay,
|
||
};
|
||
|
||
HookMp4RecordingCallbacks();
|
||
|
||
CodelyLogger.Log(
|
||
$"[ManageScreenshot] Started MP4 recording {id}: " +
|
||
$"{duration:F2}s @ capture {fps}fps → encode {encodeFps}fps, scale={scale:F2}");
|
||
|
||
handle = ToHandle(s_Mp4Recording);
|
||
return true;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
CleanupMp4Recording(false);
|
||
error = Response.Error($"Failed to start MP4 recording: {e.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
internal static bool IsGameViewMp4RecordingFinished(Mp4RecordingHandle handle)
|
||
{
|
||
return GetGameViewMp4RecordingStatus(handle) == Mp4RecordingStatus.Finished;
|
||
}
|
||
|
||
internal static Mp4RecordingStatus GetGameViewMp4RecordingStatus(
|
||
Mp4RecordingHandle handle)
|
||
{
|
||
if (handle == null)
|
||
return Mp4RecordingStatus.Unavailable;
|
||
|
||
if (s_Mp4Recording != null)
|
||
{
|
||
if (!string.Equals(handle.Id, s_Mp4Recording.Id, StringComparison.Ordinal))
|
||
return Mp4RecordingStatus.Unavailable;
|
||
|
||
return s_Mp4Recording.Finished
|
||
? Mp4RecordingStatus.Finished
|
||
: Mp4RecordingStatus.Pending;
|
||
}
|
||
|
||
return string.Equals(handle.Id, s_LastCollectedRecordingId, StringComparison.Ordinal)
|
||
? Mp4RecordingStatus.Finished
|
||
: Mp4RecordingStatus.Unavailable;
|
||
}
|
||
|
||
internal static object CollectGameViewMp4Recording(Mp4RecordingHandle handle)
|
||
{
|
||
if (handle == null)
|
||
return Response.Error("The Game View MP4 recording is no longer available.");
|
||
|
||
if (s_Mp4Recording == null)
|
||
{
|
||
if (string.Equals(handle.Id, s_LastCollectedRecordingId, StringComparison.Ordinal))
|
||
return s_LastCollectedRecordingResult;
|
||
|
||
return Response.Error("The Game View MP4 recording is no longer available.");
|
||
}
|
||
|
||
if (!string.Equals(handle.Id, s_Mp4Recording.Id, StringComparison.Ordinal))
|
||
return Response.Error("The Game View MP4 recording is no longer available.");
|
||
|
||
if (!s_Mp4Recording.Finished)
|
||
return Response.Error("The Game View MP4 recording has not finished yet.");
|
||
|
||
object result = s_Mp4Recording.Result ??
|
||
Response.Error("MP4 recording finished without producing a result.");
|
||
s_LastCollectedRecordingId = handle.Id;
|
||
s_LastCollectedRecordingResult = result;
|
||
ReleaseRecordingWaiter(true);
|
||
return result;
|
||
}
|
||
|
||
internal static void CancelGameViewMp4Recording(Mp4RecordingHandle handle)
|
||
{
|
||
if (handle == null || s_Mp4Recording == null ||
|
||
!string.Equals(handle.Id, s_Mp4Recording.Id, StringComparison.Ordinal))
|
||
return;
|
||
|
||
ReleaseRecordingWaiter(false);
|
||
}
|
||
|
||
private static Mp4RecordingHandle ToHandle(Mp4RecordingSession session)
|
||
{
|
||
return new Mp4RecordingHandle
|
||
{
|
||
Id = session.Id,
|
||
DurationSeconds = session.DurationSeconds,
|
||
Fps = session.Fps,
|
||
EncodeFps = session.EncodeFps,
|
||
TargetFrames = session.TargetFrames,
|
||
Scale = session.Scale,
|
||
OutputPath = session.OutputPath,
|
||
};
|
||
}
|
||
|
||
private static void ReleaseRecordingWaiter(bool preserveOutput)
|
||
{
|
||
if (s_Mp4Recording == null) return;
|
||
s_Mp4Recording.Waiters--;
|
||
if (s_Mp4Recording.Waiters > 0)
|
||
return;
|
||
|
||
CleanupMp4Recording(preserveOutput);
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when another Game View MP4 recording is already active or finished
|
||
/// but not yet collected. Callers that want to start a new recording must
|
||
/// fail closed instead of discarding that session.
|
||
/// </summary>
|
||
internal static bool TryGetActiveGameViewMp4RecordingConflict(out object error)
|
||
{
|
||
error = null;
|
||
if (s_Mp4Recording == null)
|
||
return false;
|
||
|
||
string state = s_Mp4Recording.Finished ? "finished but not yet collected" : "still active";
|
||
string ownerCommand = string.IsNullOrEmpty(s_Mp4Recording.OwnerCommandName)
|
||
? "exec_runtime_script or legacy execute_csharp_script"
|
||
: s_Mp4Recording.OwnerCommandName;
|
||
string resolution = s_Mp4Recording.Owner == Mp4RecordingOwner.Split
|
||
? "Collect it with finish_game_view_recording and the matching recording_id."
|
||
: $"Wait for the owning {ownerCommand} command " +
|
||
"to collect it automatically.";
|
||
error = Response.Error(
|
||
$"An MP4 recording is already {state} (recording_id={s_Mp4Recording.Id}). " +
|
||
resolution);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Explicit leftover cleanup for tests and aborted encode jobs.
|
||
/// Do not call this to make room for a new record_game_view attempt;
|
||
/// an active session must fail closed so the owner can collect it.
|
||
/// </summary>
|
||
internal static void DiscardActiveGameViewMp4Recording()
|
||
{
|
||
s_LastCollectedRecordingId = null;
|
||
s_LastCollectedRecordingResult = null;
|
||
if (s_Mp4Recording == null) return;
|
||
|
||
CodelyLogger.LogWarning(
|
||
$"[ManageScreenshot] Discarding leftover MP4 recording {s_Mp4Recording.Id}.");
|
||
CleanupMp4Recording(false);
|
||
}
|
||
|
||
private static void HookMp4RecordingCallbacks()
|
||
{
|
||
EditorApplication.update -= TickGameViewMp4Recording;
|
||
EditorApplication.update += TickGameViewMp4Recording;
|
||
}
|
||
|
||
private static void RememberFinishedMp4Result(Mp4RecordingSession session, object result)
|
||
{
|
||
if (session == null) return;
|
||
session.Result = result;
|
||
session.ResultJson = result == null ? null : JsonConvert.SerializeObject(result);
|
||
session.Finished = true;
|
||
}
|
||
|
||
private static void TickGameViewMp4Recording()
|
||
{
|
||
if (s_TickingMp4) return;
|
||
|
||
Mp4RecordingSession session = s_Mp4Recording;
|
||
if (session == null || session.Finished)
|
||
{
|
||
if (session == null || session.Finished)
|
||
EditorApplication.update -= TickGameViewMp4Recording;
|
||
return;
|
||
}
|
||
|
||
s_TickingMp4 = true;
|
||
try
|
||
{
|
||
TickGameViewMp4RecordingBody(session);
|
||
}
|
||
finally
|
||
{
|
||
s_TickingMp4 = false;
|
||
}
|
||
}
|
||
|
||
private static void TickGameViewMp4RecordingBody(Mp4RecordingSession session)
|
||
{
|
||
double now = EditorApplication.timeSinceStartup;
|
||
if (now < session.NextCaptureTime) return;
|
||
|
||
if (!EditorApplication.isPlaying)
|
||
{
|
||
FinalizeMp4Recording(session, "Play Mode ended before the requested recording duration.");
|
||
return;
|
||
}
|
||
|
||
session.AttemptedFrames++;
|
||
Texture2D frame = null;
|
||
bool writingNativeFrame = false;
|
||
try
|
||
{
|
||
frame = CaptureGameViewTexture();
|
||
if (frame != null)
|
||
frame = ScaleTextureByFactor(frame, session.Scale);
|
||
if (frame != null)
|
||
frame = PrepareMp4Frame(frame);
|
||
|
||
if (frame == null)
|
||
{
|
||
session.DroppedFrames++;
|
||
}
|
||
else if (session.CapturedFrames > 0 &&
|
||
(frame.width != session.Width || frame.height != session.Height))
|
||
{
|
||
session.DroppedFrames++;
|
||
UnityEngine.Object.DestroyImmediate(frame);
|
||
frame = null;
|
||
CodelyLogger.LogWarning(
|
||
"[ManageScreenshot] Dropped MP4 frame because the Game View size changed during recording.");
|
||
}
|
||
else
|
||
{
|
||
if (session.CapturedFrames == 0)
|
||
{
|
||
session.Width = frame.width;
|
||
session.Height = frame.height;
|
||
session.ActualStartTime = now;
|
||
writingNativeFrame = true;
|
||
session.Encoder = CreateMp4Encoder(
|
||
session.EncoderPath, session.Width, session.Height, session.EncodeFps);
|
||
}
|
||
|
||
writingNativeFrame = true;
|
||
if (session.Encoder == null || !session.Encoder.AddFrame(frame))
|
||
throw new InvalidOperationException(
|
||
"The Windows native media encoder rejected a Game View frame.");
|
||
|
||
session.CapturedFrames++;
|
||
session.ActualEndTime = now;
|
||
UnityEngine.Object.DestroyImmediate(frame);
|
||
frame = null;
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
if (frame != null) UnityEngine.Object.DestroyImmediate(frame);
|
||
if (writingNativeFrame)
|
||
{
|
||
FailMp4Recording(
|
||
session,
|
||
$"Failed to encode Game View MP4 frame {session.AttemptedFrames - 1} " +
|
||
$"with the Windows native media encoder: {e.Message}");
|
||
return;
|
||
}
|
||
|
||
session.DroppedFrames++;
|
||
CodelyLogger.LogWarning(
|
||
$"[ManageScreenshot] MP4 frame {session.AttemptedFrames - 1} capture failed: {e.Message}");
|
||
}
|
||
|
||
session.NextCaptureTime = now + 1.0 / session.Fps;
|
||
if (session.AttemptedFrames >= session.TargetFrames)
|
||
FinalizeMp4Recording(session, null);
|
||
}
|
||
|
||
private static IEnumerator FinishGameViewMp4RecordingCoroutine(
|
||
JObject p, Action<object> setResult)
|
||
{
|
||
Mp4RecordingSession session = s_Mp4Recording;
|
||
if (session == null)
|
||
{
|
||
setResult(Response.Error("No Game View MP4 recording exists. Call start_game_view_recording first."));
|
||
yield break;
|
||
}
|
||
|
||
string requestedId = p?["recording_id"]?.ToString();
|
||
if (string.IsNullOrWhiteSpace(requestedId))
|
||
{
|
||
setResult(Response.Error(
|
||
"'recording_id' is required for finish_game_view_recording. " +
|
||
"Use the ID returned by start_game_view_recording."));
|
||
yield break;
|
||
}
|
||
|
||
if (!string.Equals(requestedId, session.Id, StringComparison.Ordinal))
|
||
{
|
||
setResult(Response.Error(
|
||
$"recording_id '{requestedId}' does not match the active recording '{session.Id}'."));
|
||
yield break;
|
||
}
|
||
|
||
if (session.Owner != Mp4RecordingOwner.Split)
|
||
{
|
||
setResult(Response.Error(
|
||
"finish_game_view_recording cannot collect a recording owned by " +
|
||
"exec_runtime_script or legacy execute_csharp_script; " +
|
||
"that command collects its recording automatically."));
|
||
yield break;
|
||
}
|
||
|
||
while (!session.Finished)
|
||
yield return null;
|
||
|
||
object result = session.Result ??
|
||
Response.Error("MP4 recording finished without producing a result.");
|
||
s_LastCollectedRecordingId = session.Id;
|
||
s_LastCollectedRecordingResult = result;
|
||
setResult(result);
|
||
ReleaseRecordingWaiter(true);
|
||
}
|
||
|
||
internal static void FinalizeActiveMp4RecordingForTests(string warning = null)
|
||
{
|
||
FinalizeMp4Recording(s_Mp4Recording, warning);
|
||
}
|
||
|
||
private static IMp4FrameEncoder CreateMp4Encoder(
|
||
string outputPath, int width, int height, int fps)
|
||
{
|
||
return Mp4EncoderFactoryOverride != null
|
||
? Mp4EncoderFactoryOverride(outputPath, width, height, fps)
|
||
: PlatformMp4Encoder.Create(outputPath, width, height, fps);
|
||
}
|
||
|
||
private static string PlatformEncoderName
|
||
{
|
||
get
|
||
{
|
||
#if UNITY_EDITOR_WIN
|
||
return "windows_media_foundation";
|
||
#elif UNITY_EDITOR_OSX
|
||
return "avfoundation";
|
||
#else
|
||
return "unsupported";
|
||
#endif
|
||
}
|
||
}
|
||
|
||
private static void FinalizeMp4Recording(Mp4RecordingSession session, string warning)
|
||
{
|
||
if (session == null || session.Finished) return;
|
||
EditorApplication.update -= TickGameViewMp4Recording;
|
||
|
||
if (session.CapturedFrames == 0)
|
||
{
|
||
DisposeMp4EncoderBestEffort(session);
|
||
RememberFinishedMp4Result(session, Response.Error(
|
||
"Failed to capture any Game View frames for MP4 recording."));
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
IMp4FrameEncoder encoder = session.Encoder;
|
||
session.Encoder = null;
|
||
encoder?.Dispose();
|
||
|
||
if (!File.Exists(session.EncoderPath))
|
||
throw new IOException("The Windows native media encoder produced no output file.");
|
||
|
||
if (!string.Equals(session.EncoderPath, session.OutputPath, StringComparison.Ordinal))
|
||
File.Move(session.EncoderPath, session.OutputPath);
|
||
|
||
long sizeBytes = new FileInfo(session.OutputPath).Length;
|
||
double actualCaptureSeconds = session.ActualEndTime > session.ActualStartTime
|
||
? session.ActualEndTime - session.ActualStartTime
|
||
: 0.0;
|
||
|
||
CodelyLogger.Log(
|
||
$"[ManageScreenshot] Saved MP4 with Windows Media Foundation: {session.OutputPath} " +
|
||
$"({session.CapturedFrames} frames, capture {session.Fps}fps → encode {session.EncodeFps}fps)");
|
||
|
||
RememberFinishedMp4Result(session, Response.Success("Game View MP4 recording completed.", new
|
||
{
|
||
recording_id = session.Id,
|
||
path = session.OutputPath,
|
||
format = "mp4",
|
||
codec = "h264",
|
||
encoder = PlatformEncoderName,
|
||
width = session.Width,
|
||
height = session.Height,
|
||
frames = session.CapturedFrames,
|
||
captureFps = session.Fps,
|
||
encodeFps = session.EncodeFps,
|
||
durationSeconds = (double)session.CapturedFrames / session.EncodeFps,
|
||
actualCaptureSeconds,
|
||
droppedFrames = session.DroppedFrames,
|
||
sizeBytes,
|
||
warning,
|
||
note = session.EncodeFps < session.Fps
|
||
? $"Slow-motion MP4: captured at {session.Fps}fps over {actualCaptureSeconds:F1}s of real-time, " +
|
||
$"encoded at {session.EncodeFps}fps so a {session.EncodeFps}fps-sampling VLM sees every frame. " +
|
||
$"Playback duration is {(double)session.CapturedFrames / session.EncodeFps:F1}s " +
|
||
$"({(double)session.Fps / session.EncodeFps:F1}x slower than real-time). " +
|
||
$"Interpret motion accordingly — objects move slower than they did in the actual game."
|
||
: null,
|
||
}));
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
FailMp4Recording(
|
||
session,
|
||
$"Failed to finalize MP4 with the Windows native media encoder: {e.Message}");
|
||
}
|
||
}
|
||
|
||
private static void FailMp4Recording(Mp4RecordingSession session, string message)
|
||
{
|
||
if (session == null || session.Finished) return;
|
||
EditorApplication.update -= TickGameViewMp4Recording;
|
||
DisposeMp4EncoderBestEffort(session);
|
||
try
|
||
{
|
||
DeleteMp4SessionFiles(session);
|
||
}
|
||
catch { }
|
||
RememberFinishedMp4Result(session, Response.Error(message));
|
||
}
|
||
|
||
private static void DisposeMp4EncoderBestEffort(Mp4RecordingSession session)
|
||
{
|
||
if (session?.Encoder == null) return;
|
||
IMp4FrameEncoder encoder = session.Encoder;
|
||
session.Encoder = null;
|
||
try { encoder.Dispose(); }
|
||
catch (Exception e)
|
||
{
|
||
CodelyLogger.LogWarning(
|
||
$"[ManageScreenshot] Failed to dispose the native MP4 encoder: {e.Message}");
|
||
}
|
||
}
|
||
|
||
private static bool TryResolveMp4OutputDirectory(
|
||
string requested, out string resolved, out string error)
|
||
{
|
||
resolved = null;
|
||
error = null;
|
||
try
|
||
{
|
||
string projectRoot = Path.GetFullPath(Path.GetDirectoryName(Application.dataPath));
|
||
string candidate = string.IsNullOrWhiteSpace(requested)
|
||
? Path.Combine(projectRoot, "screenshots")
|
||
: requested.Trim().Trim('"');
|
||
if (!Path.IsPathRooted(candidate))
|
||
candidate = Path.Combine(projectRoot, candidate);
|
||
|
||
candidate = Path.GetFullPath(candidate);
|
||
#if UNITY_EDITOR_WIN
|
||
StringComparison comparison = StringComparison.OrdinalIgnoreCase;
|
||
#else
|
||
StringComparison comparison = StringComparison.Ordinal;
|
||
#endif
|
||
string rootWithSeparator =
|
||
projectRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) +
|
||
Path.DirectorySeparatorChar;
|
||
if (!string.Equals(candidate, projectRoot, comparison) &&
|
||
!candidate.StartsWith(rootWithSeparator, comparison))
|
||
{
|
||
error =
|
||
"'path' for Game View recording must stay inside the current Unity project.";
|
||
return false;
|
||
}
|
||
|
||
resolved = candidate;
|
||
return true;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
error = $"Invalid Game View recording output path: {e.Message}";
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Unity's native MediaEncoder re-opens the MP4 during Dispose to rewrite the track
|
||
/// timestamps, and that pass fails (WindowsVideoMedia 0x8000ffff, "Erase timestamps")
|
||
/// when the path contains non-ASCII characters. In that case the encoder targets a
|
||
/// staging file under %TEMP%; FinalizeMp4Recording moves it into place after Dispose
|
||
/// succeeds, since System.IO handles Unicode paths correctly.
|
||
/// </summary>
|
||
private static string ResolveMp4EncoderPath(string outputPath)
|
||
{
|
||
#if UNITY_EDITOR_WIN
|
||
if (outputPath.Any(c => c > 127))
|
||
return Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".mp4");
|
||
#endif
|
||
return outputPath;
|
||
}
|
||
|
||
private static void DeleteMp4SessionFiles(Mp4RecordingSession session)
|
||
{
|
||
if (!string.IsNullOrEmpty(session.OutputPath) && File.Exists(session.OutputPath))
|
||
File.Delete(session.OutputPath);
|
||
if (!string.IsNullOrEmpty(session.EncoderPath) &&
|
||
!string.Equals(session.EncoderPath, session.OutputPath, StringComparison.Ordinal) &&
|
||
File.Exists(session.EncoderPath))
|
||
File.Delete(session.EncoderPath);
|
||
}
|
||
|
||
private static void CleanupMp4Recording(bool preserveOutput)
|
||
{
|
||
EditorApplication.update -= TickGameViewMp4Recording;
|
||
Mp4RecordingSession session = s_Mp4Recording;
|
||
s_Mp4Recording = null;
|
||
if (session == null) return;
|
||
|
||
DisposeMp4EncoderBestEffort(session);
|
||
if (!preserveOutput)
|
||
{
|
||
try
|
||
{
|
||
DeleteMp4SessionFiles(session);
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
|
||
// ==================== Incremental in-memory GIF capture ====================
|
||
//
|
||
// A stateful, frame-by-frame Game View capture API for callers OUTSIDE the command
|
||
// pipeline (other editor modules call these directly — they are NOT routed through
|
||
// HandleCommand). Unlike CaptureFramesToGif, which owns its own frame pacing inside a
|
||
// coroutine, here the CALLER decides when each frame is taken: one Texture2D is grabbed
|
||
// from the Game View per call and held in memory until EndCaptureToGif encodes the whole
|
||
// sequence into an animated .gif on disk.
|
||
//
|
||
// Typical usage (all calls must be on the main thread; e.g. once per editor tick):
|
||
// ManageScreenshot.BeginCapture(); // starts a session + grabs frame 0
|
||
// ManageScreenshot.CaptureFrame(); // frame 1
|
||
// ManageScreenshot.CaptureFrame(); // frame 2 …
|
||
// string path = ManageScreenshot.EndCaptureToGif("Clip.gif"); // encode + write, returns full path
|
||
//
|
||
// Per-frame GIF display durations are derived from the real wall-clock interval between
|
||
// successive captures, so playback tracks the caller's actual pacing.
|
||
//
|
||
// Not thread-safe and not reentrant: only one session may be active at a time. Calling
|
||
// BeginCapture while a session is already open discards the previous (uncaptured) frames.
|
||
|
||
private static List<Texture2D> s_SessionFrames;
|
||
private static List<double> s_SessionTimes; // EditorApplication.timeSinceStartup per frame
|
||
private static float s_SessionScale = 1f;
|
||
private static bool s_SessionActive;
|
||
|
||
/// <summary>True while a BeginCapture session is open (awaiting more frames / a save).</summary>
|
||
public static bool IsCapturing => s_SessionActive;
|
||
|
||
/// <summary>Number of frames captured in the current session (0 when none active).</summary>
|
||
public static int CapturedFrameCount => s_SessionFrames?.Count ?? 0;
|
||
|
||
/// <summary>
|
||
/// Starts a new capture session and immediately grabs the first Game View frame into memory.
|
||
/// </summary>
|
||
/// <param name="scale">Resolution scale (0.25–1.0) applied to every captured frame to keep
|
||
/// the GIF small; 1.0 = full Game View resolution.</param>
|
||
/// <returns>True if the first frame was captured; false if no Game View frame was available.</returns>
|
||
public static bool BeginCapture(float scale = 1f)
|
||
{
|
||
if (s_SessionActive)
|
||
{
|
||
CodelyLogger.LogWarning("[ManageScreenshot] BeginCapture called while a session was already active; discarding the previous session.");
|
||
DisposeSession();
|
||
}
|
||
|
||
s_SessionScale = Mathf.Clamp(scale, 0.25f, 1f);
|
||
s_SessionFrames = new List<Texture2D>();
|
||
s_SessionTimes = new List<double>();
|
||
s_SessionActive = true;
|
||
|
||
return CaptureFrame();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Captures one more Game View frame into the active session's in-memory buffer.
|
||
/// </summary>
|
||
/// <returns>True if a frame was captured; false when no session is active or the Game
|
||
/// View produced nothing.</returns>
|
||
public static bool CaptureFrame()
|
||
{
|
||
if (!s_SessionActive)
|
||
{
|
||
CodelyLogger.LogWarning("[ManageScreenshot] CaptureFrame called with no active session. Call BeginCapture first.");
|
||
return false;
|
||
}
|
||
|
||
Texture2D tex = null;
|
||
try
|
||
{
|
||
tex = CaptureGameViewTexture();
|
||
if (tex == null)
|
||
{
|
||
CodelyLogger.LogWarning("[ManageScreenshot] CaptureFrame produced no texture (is a Game View open?).");
|
||
return false;
|
||
}
|
||
|
||
// ScaleTextureByFactor destroys the source and returns the scaled copy (no-op at
|
||
// 1.0). It only destroys the source as its final step, so if it throws earlier
|
||
// 'tex' still points at the original and is cleaned up in the catch below.
|
||
tex = ScaleTextureByFactor(tex, s_SessionScale);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
if (tex != null) UnityEngine.Object.DestroyImmediate(tex);
|
||
CodelyLogger.LogWarning($"[ManageScreenshot] CaptureFrame failed: {e.Message}");
|
||
return false;
|
||
}
|
||
|
||
s_SessionFrames.Add(tex);
|
||
s_SessionTimes.Add(EditorApplication.timeSinceStartup);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ends the active session, encodes all buffered frames into an animated .gif, writes it to
|
||
/// disk, and frees the in-memory frames.
|
||
/// </summary>
|
||
/// <param name="filePath">Destination path. If it has no directory it is written under
|
||
/// <ProjectRoot>/screenshots; a missing ".gif" extension is appended. Pass null/empty
|
||
/// for an auto-named file in the screenshots folder.</param>
|
||
/// <param name="fps">Nominal playback frames per second (used for the final frame's delay
|
||
/// and clamped to 1–50).</param>
|
||
/// <param name="colorCount">GIF palette size (2–256).</param>
|
||
/// <returns>The full path of the written file, or null if nothing was captured / on error.</returns>
|
||
public static string EndCaptureToGif(string filePath, int fps = 25, int colorCount = 128)
|
||
{
|
||
if (!s_SessionActive)
|
||
{
|
||
CodelyLogger.LogWarning("[ManageScreenshot] EndCaptureToGif called with no active session.");
|
||
return null;
|
||
}
|
||
|
||
// Detach the session state up front so an early return / exception cannot leave a
|
||
// half-open session behind; the frames are disposed in the finally below.
|
||
List<Texture2D> frames = s_SessionFrames;
|
||
List<double> times = s_SessionTimes;
|
||
s_SessionActive = false;
|
||
s_SessionFrames = null;
|
||
s_SessionTimes = null;
|
||
|
||
try
|
||
{
|
||
if (frames.Count == 0)
|
||
{
|
||
CodelyLogger.LogWarning("[ManageScreenshot] EndCaptureToGif: no frames captured; nothing written.");
|
||
return null;
|
||
}
|
||
|
||
fps = Mathf.Clamp(fps, 1, 50);
|
||
colorCount = Mathf.Clamp(colorCount, 2, 256);
|
||
int nominalDelayCs = Mathf.Max(1, Mathf.RoundToInt(100f / fps));
|
||
|
||
// Each frame's display duration (1/100 s) is the real interval that elapsed
|
||
// before the NEXT frame was taken; the last frame uses the nominal fps interval.
|
||
List<int> delaysCs = new List<int>(frames.Count);
|
||
for (int i = 1; i < frames.Count; i++)
|
||
{
|
||
int gapCs = Mathf.Max(1, Mathf.RoundToInt((float)((times[i] - times[i - 1]) * 100.0)));
|
||
delaysCs.Add(gapCs);
|
||
}
|
||
delaysCs.Add(nominalDelayCs);
|
||
|
||
// GIF frames must share dimensions; drop any odd-sized frame (e.g. a Game View
|
||
// resize mid-capture), keeping the delay list aligned.
|
||
int w = frames[0].width, h = frames[0].height;
|
||
List<Texture2D> usable = new List<Texture2D>(frames.Count);
|
||
List<int> usableDelay = new List<int>(frames.Count);
|
||
for (int i = 0; i < frames.Count; i++)
|
||
{
|
||
Texture2D f = frames[i];
|
||
if (f == null) continue;
|
||
if (f.width == w && f.height == h)
|
||
{
|
||
usable.Add(f);
|
||
usableDelay.Add(i < delaysCs.Count ? delaysCs[i] : nominalDelayCs);
|
||
}
|
||
else
|
||
{
|
||
CodelyLogger.LogWarning("[ManageScreenshot] Dropping session GIF frame with mismatched size.");
|
||
}
|
||
}
|
||
if (usable.Count == 0) { usable.Add(frames[0]); usableDelay.Add(nominalDelayCs); }
|
||
|
||
byte[] bytes = GifEncoder.Encode(usable, colorCount, usableDelay);
|
||
|
||
// Resolve destination directory + filename.
|
||
string defaultDir = Path.Combine(Path.GetDirectoryName(Application.dataPath), "screenshots");
|
||
string dir, fileName;
|
||
if (string.IsNullOrEmpty(filePath))
|
||
{
|
||
dir = defaultDir;
|
||
fileName = $"Capture_{Timestamp()}.gif";
|
||
}
|
||
else
|
||
{
|
||
if (!filePath.EndsWith(".gif", StringComparison.OrdinalIgnoreCase))
|
||
filePath += ".gif";
|
||
dir = Path.GetDirectoryName(filePath);
|
||
fileName = Path.GetFileName(filePath);
|
||
if (string.IsNullOrEmpty(dir)) dir = defaultDir;
|
||
}
|
||
|
||
Directory.CreateDirectory(dir);
|
||
string savePath = Path.Combine(dir, fileName);
|
||
File.WriteAllBytes(savePath, bytes);
|
||
|
||
CodelyLogger.Log($"[ManageScreenshot] Saved session GIF: {savePath} ({usable.Count} frames @ {fps}fps)");
|
||
return savePath;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
CodelyLogger.LogError($"[ManageScreenshot] EndCaptureToGif failed: {e}");
|
||
return null;
|
||
}
|
||
finally
|
||
{
|
||
foreach (Texture2D f in frames)
|
||
if (f != null) UnityEngine.Object.DestroyImmediate(f);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Aborts the active session (if any) without writing a file, freeing all buffered frames.
|
||
/// </summary>
|
||
public static void AbortCapture()
|
||
{
|
||
if (!s_SessionActive) return;
|
||
CodelyLogger.Log($"[ManageScreenshot] Capture session aborted ({CapturedFrameCount} frames discarded).");
|
||
DisposeSession();
|
||
}
|
||
|
||
private static void DisposeSession()
|
||
{
|
||
if (s_SessionFrames != null)
|
||
foreach (Texture2D f in s_SessionFrames)
|
||
if (f != null) UnityEngine.Object.DestroyImmediate(f);
|
||
|
||
s_SessionFrames = null;
|
||
s_SessionTimes = null;
|
||
s_SessionActive = false;
|
||
}
|
||
|
||
// ======================== capture_game_view ========================
|
||
// Parameters:
|
||
// over_time (string) – "N" or "NxS": capture N frames, yielding S editor ticks between each.
|
||
// path, filename
|
||
private static IEnumerator CaptureGameViewCoroutine(JObject p, Action<object> setResult)
|
||
{
|
||
string customPath = p["path"]?.ToString();
|
||
string customFile = p["filename"]?.ToString();
|
||
float scale = ParseScale(p);
|
||
GifParams gif = ParseGifParams(p);
|
||
|
||
// Legacy Game View still frames stay available so older clients keep working.
|
||
// New clients should not call this action; they record an MP4 instead.
|
||
|
||
// --- GIF capture: record N frames and encode an animated .gif ---
|
||
if (gif != null)
|
||
{
|
||
IEnumerator gifCo = CaptureFramesToGif(
|
||
gif,
|
||
() => { Texture2D t = CaptureGameViewTexture(); return t == null ? null : ScaleTextureByFactor(t, scale); },
|
||
customPath, customFile, "GameView", setResult,
|
||
beforeFrame: EnsureGameViewFresh);
|
||
while (gifCo.MoveNext()) yield return gifCo.Current;
|
||
yield break;
|
||
}
|
||
|
||
OverTimeParams ot = ParseOverTime(p["over_time"]);
|
||
List<Texture2D> captures = new List<Texture2D>();
|
||
|
||
for (int i = 0; i < ot.Count; i++)
|
||
{
|
||
if (i > 0)
|
||
{
|
||
int steps = Math.Max(1, ot.StepsPerInterval);
|
||
for (int s = 0; s < steps; s++)
|
||
yield return null;
|
||
}
|
||
|
||
IEnumerator fresh = EnsureGameViewFresh();
|
||
while (fresh.MoveNext()) yield return fresh.Current;
|
||
|
||
Texture2D tex = CaptureGameViewTexture();
|
||
if (tex == null) continue;
|
||
tex = ScaleTextureByFactor(tex, scale);
|
||
if (ot.Count > 1) DrawLabelOnTexture(tex, FrameLabel(i));
|
||
captures.Add(tex);
|
||
}
|
||
|
||
if (captures.Count == 0)
|
||
setResult(Response.Error("Failed to capture Game View. Ensure a Game View window exists."));
|
||
else
|
||
setResult(BuildImageResponse(StitchOrSingle(captures), customPath, customFile, "GameView"));
|
||
}
|
||
|
||
// ======================== capture_scene_view ========================
|
||
private static IEnumerator CaptureSceneViewCoroutine(JObject p, Action<object> setResult)
|
||
{
|
||
SceneView sv = SceneView.lastActiveSceneView;
|
||
if (sv == null) { setResult(Response.Error("No active Scene View found. Please ensure a Scene View is open.")); yield break; }
|
||
Camera sceneCam = sv.camera;
|
||
if (sceneCam == null) { setResult(Response.Error("Scene View camera not found.")); yield break; }
|
||
|
||
int width = p["width"]?.ToObject<int?>() ?? 960;
|
||
int height = p["height"]?.ToObject<int?>() ?? 540;
|
||
string customPath = p["path"]?.ToString();
|
||
string customFile = p["filename"]?.ToString();
|
||
string viewParam = p["view"]?.ToString()?.ToLower() ?? "current";
|
||
bool orthographic = p["orthographic"]?.ToObject<bool?>() ?? false;
|
||
DrawCameraMode drawMode = ParseSceneViewDrawMode(sv, p["render_mode"]);
|
||
GifParams gif = ParseGifParams(p);
|
||
OverTimeParams ot = ParseOverTime(p["over_time"]);
|
||
|
||
Bounds? selBounds = null;
|
||
string captureWarning = null;
|
||
JObject focusObjEntry = p["focus_object"] as JObject;
|
||
if (focusObjEntry != null)
|
||
{
|
||
string objPath = focusObjEntry["focusobject"]?.ToString();
|
||
int objIndex = focusObjEntry["index"]?.ToObject<int?>() ?? 0;
|
||
if (!string.IsNullOrEmpty(objPath))
|
||
{
|
||
GameObject go = FindGameObjectByPath(objPath, objIndex);
|
||
if (go != null)
|
||
{
|
||
selBounds = CalculateSingleObjectBounds(go);
|
||
sv.Frame(selBounds.Value, true);
|
||
}
|
||
else
|
||
{
|
||
captureWarning = $"focus_object not found: path='{objPath}' index={objIndex}. Falling back to scene bounds.";
|
||
CodelyLogger.LogWarning($"[ManageScreenshot] {captureWarning}");
|
||
}
|
||
}
|
||
}
|
||
|
||
List<string> views = GetViewList(viewParam);
|
||
List<Texture2D> captures = new List<Texture2D>();
|
||
Quaternion origRot = sv.rotation;
|
||
bool origOrtho = sv.orthographic;
|
||
Vector3 origCamPosition = sceneCam.transform.position;
|
||
Quaternion origCamRotation = sceneCam.transform.rotation;
|
||
bool origCamOrtho = sceneCam.orthographic;
|
||
float origCamOrthoSize = sceneCam.orthographicSize;
|
||
Bounds captureBounds = selBounds ?? GetSceneBounds();
|
||
|
||
try
|
||
{
|
||
// --- GIF capture: record N frames over time ---
|
||
// Each frame renders every requested view and stitches them into a single
|
||
// grid texture (same layout as a still multi-view capture), so a multi-view
|
||
// GIF animates the whole grid. A single view yields an ungridded frame.
|
||
if (gif != null)
|
||
{
|
||
bool multiView = views.Count > 1;
|
||
IEnumerator gifCo = CaptureFramesToGif(
|
||
gif,
|
||
() =>
|
||
{
|
||
List<Texture2D> viewTextures = new List<Texture2D>(views.Count);
|
||
foreach (string view in views)
|
||
{
|
||
if (view == "current")
|
||
{
|
||
sceneCam.transform.SetPositionAndRotation(origCamPosition, origCamRotation);
|
||
sceneCam.orthographic = origCamOrtho;
|
||
sceneCam.orthographicSize = origCamOrthoSize;
|
||
}
|
||
else
|
||
{
|
||
PositionCameraForView(sceneCam, captureBounds, view, orthographic);
|
||
}
|
||
|
||
string frameWarning;
|
||
Texture2D t = RenderSceneViewCameraToTexture(
|
||
sv, sceneCam, width, height, drawMode,
|
||
view == "current" ? sv.pivot : captureBounds.center,
|
||
out frameWarning);
|
||
if (!string.IsNullOrEmpty(frameWarning))
|
||
{
|
||
captureWarning = AppendWarning(captureWarning, frameWarning);
|
||
CodelyLogger.LogWarning($"[ManageScreenshot] {frameWarning}");
|
||
}
|
||
if (t == null) continue;
|
||
if (multiView) DrawLabelOnTexture(t, ViewAbbreviation(view));
|
||
viewTextures.Add(t);
|
||
}
|
||
// StitchOrSingle merges the per-view textures into one frame and
|
||
// destroys the inputs; ownership of the result transfers to the GIF.
|
||
return viewTextures.Count == 0 ? null : StitchOrSingle(viewTextures);
|
||
},
|
||
customPath, customFile, "SceneView", setResult);
|
||
while (gifCo.MoveNext()) yield return gifCo.Current;
|
||
yield break;
|
||
}
|
||
|
||
for (int ti = 0; ti < ot.Count; ti++)
|
||
{
|
||
if (ti > 0)
|
||
{
|
||
int steps = Math.Max(1, ot.StepsPerInterval);
|
||
for (int s = 0; s < steps; s++)
|
||
yield return null;
|
||
}
|
||
|
||
foreach (string view in views)
|
||
{
|
||
if (view == "current")
|
||
{
|
||
sceneCam.transform.SetPositionAndRotation(origCamPosition, origCamRotation);
|
||
sceneCam.orthographic = origCamOrtho;
|
||
sceneCam.orthographicSize = origCamOrthoSize;
|
||
}
|
||
else
|
||
{
|
||
PositionCameraForView(sceneCam, captureBounds, view, orthographic);
|
||
}
|
||
|
||
string frameWarning;
|
||
Texture2D tex = RenderSceneViewCameraToTexture(
|
||
sv, sceneCam, width, height, drawMode,
|
||
view == "current" ? sv.pivot : captureBounds.center,
|
||
out frameWarning);
|
||
if (!string.IsNullOrEmpty(frameWarning))
|
||
{
|
||
captureWarning = AppendWarning(captureWarning, frameWarning);
|
||
CodelyLogger.LogWarning($"[ManageScreenshot] {frameWarning}");
|
||
}
|
||
if (tex != null)
|
||
{
|
||
tex = MaybeAutoScale(p, tex);
|
||
bool multiView = views.Count > 1;
|
||
bool multiTime = ot.Count > 1;
|
||
if (multiView && multiTime) DrawLabelOnTexture(tex, ViewAbbreviation(view) +"-"+ FrameLabel(ti));
|
||
else if (multiView) DrawLabelOnTexture(tex, ViewAbbreviation(view));
|
||
else if (multiTime) DrawLabelOnTexture(tex, FrameLabel(ti));
|
||
captures.Add(tex);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
sceneCam.transform.SetPositionAndRotation(origCamPosition, origCamRotation);
|
||
sceneCam.orthographic = origCamOrtho;
|
||
sceneCam.orthographicSize = origCamOrthoSize;
|
||
sv.rotation = origRot;
|
||
sv.orthographic = origOrtho;
|
||
}
|
||
|
||
if (captures.Count == 0)
|
||
setResult(Response.Error("Failed to capture any Scene View screenshots."));
|
||
else
|
||
setResult(BuildImageResponse(StitchOrSingle(captures), customPath, customFile, "SceneView", captureWarning));
|
||
}
|
||
|
||
// ======================== capture_asset ========================
|
||
// Instantiates one or more assets, frames them with a temporary camera, renders one or
|
||
// more views, stitches the result into a grid, and destroys all temporary objects.
|
||
//
|
||
// Supported asset types: GameObject/Prefab, Mesh, Material, Texture2D, Sprite.
|
||
//
|
||
// Parameters:
|
||
// assets (string[], required) – Asset paths (e.g. "Assets/Prefabs/Hero.prefab").
|
||
// scene – Path to a .unity scene file (e.g. "Assets/Scenes/Preview.unity").
|
||
// When provided the scene is opened additively, assets are instantiated
|
||
// into it, and the scene is closed and removed when capture finishes.
|
||
// Without this parameter assets are staged far from the origin in the
|
||
// currently active scene.
|
||
// view – Direction(s) to render; defaults to "cardinal".
|
||
// orthographic – bool, default false.
|
||
// width, height, path, filename
|
||
private static object CaptureAsset(JObject p)
|
||
{
|
||
if (EditorApplication.isPlaying)
|
||
return Response.Error("capture_asset requires Edit Mode. Exit Play Mode first.");
|
||
|
||
if (ParseGifParams(p) != null)
|
||
return Response.Error("as_gif is only supported by capture_game_view, capture_main_camera and capture_specific_camera.");
|
||
|
||
JArray assetsArray = p["assets"] as JArray;
|
||
if (assetsArray == null || assetsArray.Count == 0)
|
||
return Response.Error("'assets' must be a non-empty array of asset paths.");
|
||
|
||
Camera svCam = SceneView.lastActiveSceneView?.camera;
|
||
int width = p["width"]?.ToObject<int?>() ?? svCam?.pixelWidth ?? 960;
|
||
int height = p["height"]?.ToObject<int?>() ?? svCam?.pixelHeight ?? 540;
|
||
string customPath = p["path"]?.ToString();
|
||
string customFile = p["filename"]?.ToString();
|
||
string viewParam = p["view"]?.ToString()?.ToLower() ?? "cardinal";
|
||
bool orthographic= p["orthographic"]?.ToObject<bool?>() ?? false;
|
||
string scenePath = p["scene"]?.ToString();
|
||
|
||
// --- Open target scene additively (if requested) ---
|
||
UnityEngine.SceneManagement.Scene originalScene =
|
||
UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene();
|
||
UnityEngine.SceneManagement.Scene? targetScene = null;
|
||
|
||
if (!string.IsNullOrEmpty(scenePath))
|
||
{
|
||
string sceneRel = NormalizePath(scenePath);
|
||
if (string.IsNullOrEmpty(sceneRel) ||
|
||
(!sceneRel.EndsWith(".unity", StringComparison.OrdinalIgnoreCase)
|
||
&& !sceneRel.EndsWith(".scene", StringComparison.OrdinalIgnoreCase)))
|
||
return Response.Error($"Invalid scene path: '{scenePath}'. Must be a .unity file.");
|
||
|
||
try
|
||
{
|
||
var opened = UnityEditor.SceneManagement.EditorSceneManager.OpenScene(
|
||
sceneRel, UnityEditor.SceneManagement.OpenSceneMode.Additive);
|
||
if (!opened.IsValid())
|
||
return Response.Error($"Failed to open scene: '{scenePath}'.");
|
||
targetScene = opened;
|
||
UnityEditor.SceneManagement.EditorSceneManager.SetActiveScene(opened);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
return Response.Error($"Failed to open scene '{scenePath}': {e.Message}");
|
||
}
|
||
}
|
||
|
||
// Staging area far from origin so instantiated objects don't interfere
|
||
Vector3 stageOrigin = new Vector3(50000f, 50000f, 50000f);
|
||
|
||
List<GameObject> instantiated = new List<GameObject>();
|
||
GameObject tempCamGO = null;
|
||
|
||
try
|
||
{
|
||
// --- Instantiate assets ---
|
||
float xCursor = 0f;
|
||
foreach (var token in assetsArray)
|
||
{
|
||
string assetPath = token.ToString();
|
||
try
|
||
{
|
||
string rel = NormalizePath(assetPath);
|
||
if (string.IsNullOrEmpty(rel)) continue;
|
||
|
||
Type assetType = AssetDatabase.GetMainAssetTypeAtPath(rel);
|
||
if (assetType == null) continue;
|
||
|
||
GameObject obj = InstantiateAsset(rel, assetType);
|
||
if (obj == null) continue;
|
||
|
||
instantiated.Add(obj);
|
||
|
||
Bounds objBounds = CalculateSingleObjectBounds(obj);
|
||
obj.transform.position = stageOrigin + new Vector3(xCursor + objBounds.extents.x, 0f, 0f);
|
||
xCursor += objBounds.size.x + 1f;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
CodelyLogger.LogWarning($"[ManageScreenshot] Failed to instantiate '{assetPath}': {e.Message}");
|
||
}
|
||
}
|
||
|
||
if (instantiated.Count == 0)
|
||
return Response.Error("No assets could be instantiated. Verify the asset paths are correct.");
|
||
|
||
// --- Set up temp camera ---
|
||
tempCamGO = new GameObject("__CodelyAssetPreviewCam__");
|
||
Camera cam = tempCamGO.AddComponent<Camera>();
|
||
cam.enabled = false;
|
||
|
||
cam.cullingMask = -1;
|
||
cam.nearClipPlane = 0.01f;
|
||
cam.farClipPlane = 10000f;
|
||
cam.fieldOfView = 45f;
|
||
cam.orthographic = orthographic;
|
||
|
||
foreach (GameObject go in instantiated)
|
||
foreach (Canvas canvas in go.GetComponentsInChildren<Canvas>())
|
||
{
|
||
canvas.renderMode = RenderMode.WorldSpace;
|
||
canvas.worldCamera = cam;
|
||
}
|
||
|
||
// --- Render views ---
|
||
Bounds overallBounds = CalculateBounds(instantiated);
|
||
List<string> views = GetViewList(viewParam);
|
||
List<Texture2D> captures = new List<Texture2D>();
|
||
|
||
foreach (string view in views)
|
||
{
|
||
PositionCameraForView(cam, overallBounds, view, orthographic);
|
||
Texture2D tex = RenderCameraToTexture(cam, width, height);
|
||
if (tex != null)
|
||
{
|
||
tex = MaybeAutoScale(p, tex);
|
||
if (views.Count > 1)
|
||
DrawLabelOnTexture(tex, ViewAbbreviation(view));
|
||
captures.Add(tex);
|
||
}
|
||
}
|
||
|
||
if (captures.Count == 0)
|
||
return Response.Error("Failed to render asset preview.");
|
||
|
||
Texture2D result = StitchOrSingle(captures);
|
||
return BuildImageResponse(result, customPath, customFile, "AssetPreview");
|
||
}
|
||
finally
|
||
{
|
||
foreach (GameObject go in instantiated)
|
||
{
|
||
if (go == null) continue;
|
||
// Destroy runtime materials created by InstantiateAsset (not project assets)
|
||
foreach (Renderer r in go.GetComponentsInChildren<Renderer>())
|
||
if (r.sharedMaterial != null && !AssetDatabase.Contains(r.sharedMaterial))
|
||
UnityEngine.Object.DestroyImmediate(r.sharedMaterial);
|
||
UnityEngine.Object.DestroyImmediate(go);
|
||
}
|
||
if (tempCamGO != null) UnityEngine.Object.DestroyImmediate(tempCamGO);
|
||
|
||
// Close the additively opened scene and restore the original active scene
|
||
if (targetScene.HasValue && targetScene.Value.IsValid())
|
||
{
|
||
UnityEditor.SceneManagement.EditorSceneManager.SetActiveScene(originalScene);
|
||
UnityEditor.SceneManagement.EditorSceneManager.CloseScene(targetScene.Value, true);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ======================== capture_ui_toolkit ========================
|
||
// Renders a UIDocument (.uxml file) to a RenderTexture via UIElements PanelSettings.
|
||
// Note: UIElements rendering is driven by the Unity player loop. This method forces
|
||
// an immediate panel repaint via reflection; the first call may yield a blank image if
|
||
// no prior editor frame has processed the panel. Running the command a second time
|
||
// (after at least one editor update) should produce the correct result.
|
||
//
|
||
// Parameters:
|
||
// document_path (string, required) – Path to the .uxml asset (e.g. "Assets/UI/HUD.uxml").
|
||
// width, height – Defaults to the main GameView size or 1920×1080.
|
||
// format – "png" (default, preserves alpha) or "jpg".
|
||
// path, filename
|
||
|
||
private static IEnumerator CaptureUIToolkitCoroutine(JObject p, Action<object> setResult)
|
||
{
|
||
#if UNITY_2021_2_OR_NEWER
|
||
if (EditorApplication.isPlaying)
|
||
{
|
||
CodelyLogger.LogError("capture_ui_toolkit requires Edit Mode. Exit Play Mode first.");
|
||
setResult(Response.Error("capture_ui_toolkit requires Edit Mode. Exit Play Mode first."));
|
||
yield break;
|
||
}
|
||
|
||
string docPath = p["document_path"]?.ToString();
|
||
if (string.IsNullOrEmpty(docPath))
|
||
{
|
||
setResult(Response.Error("'document_path' parameter is required."));
|
||
yield break;
|
||
}
|
||
|
||
string rel = NormalizePath(docPath);
|
||
if (string.IsNullOrEmpty(rel))
|
||
{
|
||
setResult(Response.Error($"Invalid document_path: '{docPath}'."));
|
||
yield break;
|
||
}
|
||
|
||
string customPath = p["path"]?.ToString();
|
||
string customFile = p["filename"]?.ToString();
|
||
|
||
Vector2 gameViewSize = Handles.GetMainGameViewSize();
|
||
int width = p["width"]?.ToObject<int?>() ?? Math.Min((int)gameViewSize.x, 960);
|
||
int height = p["height"]?.ToObject<int?>() ?? Math.Min((int)gameViewSize.y, 540);
|
||
|
||
// Force reimport so any unsaved changes to the uxml are picked up
|
||
if (AssetDatabase.GetMainAssetTypeAtPath(rel) == typeof(VisualTreeAsset))
|
||
AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceSynchronousImport);
|
||
|
||
VisualTreeAsset vta = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(rel);
|
||
if (vta == null)
|
||
{
|
||
setResult(Response.Error($"UIDocument asset not found at: '{docPath}'."));
|
||
yield break;
|
||
}
|
||
|
||
PanelSettings ps = null;
|
||
RenderTexture rt = null;
|
||
GameObject go = null;
|
||
|
||
try
|
||
{
|
||
ps = ScriptableObject.CreateInstance<PanelSettings>();
|
||
ps.hideFlags = HideFlags.HideAndDontSave;
|
||
// ConstantPixelSize + scale=1 + matching DPI keeps the panel's resolved
|
||
// layout size equal to the RT dimensions regardless of the editor's HiDPI
|
||
// setting. Without this, on HiDPI displays the panel may lay out at half
|
||
// size or report a 0x0 logical viewport and render nothing.
|
||
ps.scaleMode = PanelScaleMode.ConstantPixelSize;
|
||
ps.scale = 1f;
|
||
ps.referenceDpi = 96f;
|
||
ps.fallbackDpi = 96f;
|
||
ps.clearColor = true;
|
||
// Must clear depth too — otherwise stale depth values from previous
|
||
// retry iterations cause subsequent UI quads to be depth-rejected and
|
||
// the texture stays blank.
|
||
ps.clearDepthStencil = true;
|
||
ps.colorClearValue = Color.clear;
|
||
|
||
rt = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32);
|
||
rt.hideFlags = HideFlags.HideAndDontSave;
|
||
rt.Create();
|
||
ps.targetTexture = rt;
|
||
|
||
go = new GameObject("__CodelyTempUIDocument__");
|
||
// DontSave (not HideAndDontSave): the GameObject must be a regular
|
||
// active scene member so the runtime panel system registers its panel
|
||
// for off-screen rendering.
|
||
go.hideFlags = HideFlags.DontSave;
|
||
|
||
UIDocument uiDoc = go.AddComponent<UIDocument>();
|
||
uiDoc.panelSettings = ps;
|
||
uiDoc.visualTreeAsset = vta;
|
||
|
||
ThemeStyleSheet theme = GetUIBuilderTheme(rel) ?? GetDefaultRuntimeTheme();
|
||
if (theme != null)
|
||
ps.themeStyleSheet = theme;
|
||
|
||
// The root element inherits its size from the panel, which in edit mode
|
||
// may resolve to 0x0 before the first layout tick. Pin it to the RT size
|
||
// so the very first paint produces a non-empty image.
|
||
VisualElement root = uiDoc.rootVisualElement;
|
||
if (root != null)
|
||
{
|
||
root.style.position = Position.Absolute;
|
||
root.style.left = 0;
|
||
root.style.top = 0;
|
||
root.style.width = width;
|
||
root.style.height = height;
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
if (go != null) UnityEngine.Object.DestroyImmediate(go);
|
||
if (ps != null) UnityEngine.Object.DestroyImmediate(ps);
|
||
if (rt != null) UnityEngine.Object.DestroyImmediate(rt);
|
||
setResult(Response.Error($"Failed to set up UIDocument: {e.Message}"));
|
||
yield break;
|
||
}
|
||
|
||
UIDocument uiDocRef = go.GetComponent<UIDocument>();
|
||
|
||
// Kick the runtime panel system immediately so the panel runs its first
|
||
// layout pass and produces a non-empty image before we start sampling.
|
||
TryForceUIDocumentRepaint(uiDocRef);
|
||
|
||
// In edit mode, runtime panels do not get repainted automatically — neither
|
||
// the player loop nor UIElementsRuntimeUtility.RepaintOverlayPanels() will
|
||
// flush draw commands to a targetTexture-bound PanelSettings. The actual
|
||
// work is done by TryForceUIDocumentRepaint, which on Unity 2021/2022 calls
|
||
// the panel's Repaint(Event) and on Unity 6 additionally calls the panel's
|
||
// parameterless Render() to synchronously flush UIR draws on this thread
|
||
// (no foreground / player-loop dependency). The QueuePlayerLoopUpdate /
|
||
// RepaintAllViews calls below give Yoga's layout pass a chance to settle
|
||
// between paints (useful when stylesheets reference assets that load async).
|
||
for (int i = 0; i < 10; i++)
|
||
{
|
||
EditorApplication.QueuePlayerLoopUpdate();
|
||
InternalEditorUtility.RepaintAllViews();
|
||
yield return null;
|
||
TryForceUIDocumentRepaint(uiDocRef);
|
||
}
|
||
|
||
const int maxRetry = 60;
|
||
for (int i = 0; i < maxRetry && IsRenderTextureBlack(rt); i++)
|
||
{
|
||
EditorApplication.QueuePlayerLoopUpdate();
|
||
InternalEditorUtility.RepaintAllViews();
|
||
yield return null;
|
||
TryForceUIDocumentRepaint(uiDocRef);
|
||
}
|
||
|
||
try
|
||
{
|
||
Texture2D tex = ReadRenderTexture(rt, width, height);
|
||
if (tex == null)
|
||
setResult(Response.Error("Failed to read UIDocument render texture."));
|
||
else
|
||
{
|
||
tex = MaybeAutoScale(p, tex);
|
||
setResult(BuildImageResponse(tex, customPath, customFile, "UIToolkit"));
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
setResult(Response.Error($"Failed to capture UIDocument: {e.Message}"));
|
||
}
|
||
finally
|
||
{
|
||
if (go != null) UnityEngine.Object.DestroyImmediate(go);
|
||
if (ps != null) UnityEngine.Object.DestroyImmediate(ps);
|
||
if (rt != null) UnityEngine.Object.DestroyImmediate(rt);
|
||
}
|
||
#else
|
||
setResult(Response.Error("capture_ui_toolkit requires Unity 2021.2 or newer."));
|
||
yield break;
|
||
#endif
|
||
}
|
||
|
||
// ==================== Shared helpers ====================
|
||
|
||
// Shared implementation for capture_main_camera and capture_specific_camera.
|
||
// Always renders from the camera's current position/rotation/projection.
|
||
// Supports over_time: captures across multiple simulation frames.
|
||
private static IEnumerator CaptureFromCameraCoroutine(Camera cam, JObject p, string label, Action<object> setResult)
|
||
{
|
||
int width = p["width"]?.ToObject<int?>() ?? cam?.pixelWidth ?? 960;
|
||
int height = p["height"]?.ToObject<int?>() ?? cam?.pixelHeight ?? 540;
|
||
string customPath = p["path"]?.ToString();
|
||
string customFile = p["filename"]?.ToString();
|
||
GifParams gif = ParseGifParams(p);
|
||
|
||
if (gif == null && !ValidateStaticPlayModeCapture(p, setResult))
|
||
yield break;
|
||
|
||
// --- GIF capture: record N frames and encode an animated .gif ---
|
||
if (gif != null)
|
||
{
|
||
IEnumerator gifCo = CaptureFramesToGif(
|
||
gif,
|
||
() => RenderCameraToTexture(cam, width, height),
|
||
customPath, customFile, label, setResult);
|
||
while (gifCo.MoveNext()) yield return gifCo.Current;
|
||
yield break;
|
||
}
|
||
|
||
OverTimeParams ot = ParseOverTime(p["over_time"]);
|
||
|
||
RenderTexture origRT = cam.targetTexture;
|
||
List<Texture2D> captures = new List<Texture2D>();
|
||
|
||
try
|
||
{
|
||
for (int ti = 0; ti < ot.Count; ti++)
|
||
{
|
||
if (ti > 0)
|
||
{
|
||
int steps = Math.Max(1, ot.StepsPerInterval);
|
||
for (int s = 0; s < steps; s++)
|
||
yield return null;
|
||
}
|
||
|
||
Texture2D tex = RenderCameraToTexture(cam, width, height);
|
||
if (tex != null)
|
||
{
|
||
tex = MaybeAutoScale(p, tex);
|
||
if (ot.Count > 1) DrawLabelOnTexture(tex, FrameLabel(ti));
|
||
captures.Add(tex);
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
cam.targetTexture = origRT;
|
||
}
|
||
|
||
if (captures.Count == 0)
|
||
setResult(Response.Error($"Failed to capture from camera '{label}'."));
|
||
else
|
||
setResult(BuildImageResponse(StitchOrSingle(captures), customPath, customFile, label));
|
||
}
|
||
|
||
private static Camera FindCameraByName(string name)
|
||
{
|
||
var go = GameObject.Find(name);
|
||
Camera cam = go?.GetComponent<Camera>();
|
||
if (cam != null) return cam;
|
||
#if UNITY_6000_5_OR_NEWER
|
||
foreach (Camera c in UnityEngine.Object.FindObjectsByType<Camera>())
|
||
#elif UNITY_2022_2_OR_NEWER
|
||
foreach (Camera c in UnityEngine.Object.FindObjectsByType<Camera>(FindObjectsSortMode.None))
|
||
#else
|
||
foreach (Camera c in UnityEngine.Object.FindObjectsOfType<Camera>())
|
||
#endif
|
||
if (c.name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||
return c;
|
||
return null;
|
||
}
|
||
|
||
// ==================== Scene View editor-camera capture ====================
|
||
|
||
private static DrawCameraMode ParseSceneViewDrawMode(SceneView sceneView, JToken token)
|
||
{
|
||
if (token == null || string.IsNullOrWhiteSpace(token.ToString()))
|
||
return sceneView.cameraMode.drawMode;
|
||
|
||
string raw = token.ToString().Trim().ToLower().Replace("_", "").Replace("-", "");
|
||
switch (raw)
|
||
{
|
||
case "wireframe": return DrawCameraMode.Wireframe;
|
||
case "shadedwireframe": return DrawCameraMode.TexturedWire;
|
||
default: return DrawCameraMode.Textured;
|
||
}
|
||
}
|
||
|
||
private static CaptureRenderMode ToFallbackRenderMode(DrawCameraMode drawMode)
|
||
{
|
||
if (drawMode == DrawCameraMode.Wireframe)
|
||
return CaptureRenderMode.Wireframe;
|
||
if (drawMode == DrawCameraMode.TexturedWire)
|
||
return CaptureRenderMode.ShadedWireframe;
|
||
return CaptureRenderMode.Shaded;
|
||
}
|
||
|
||
private static string AppendWarning(string existing, string next)
|
||
{
|
||
if (string.IsNullOrEmpty(next)) return existing;
|
||
if (string.IsNullOrEmpty(existing)) return next;
|
||
if (existing.Contains(next)) return existing;
|
||
return existing + " " + next;
|
||
}
|
||
|
||
private static string Timestamp() =>
|
||
DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss-fff");
|
||
|
||
private static bool TryDrawSceneViewSelection(out string warning)
|
||
{
|
||
warning = null;
|
||
if (Selection.gameObjects == null || Selection.gameObjects.Length == 0)
|
||
return true;
|
||
|
||
const BindingFlags flags =
|
||
BindingFlags.Static | BindingFlags.Instance |
|
||
BindingFlags.Public | BindingFlags.NonPublic;
|
||
try
|
||
{
|
||
Assembly editorAssembly = typeof(SceneView).Assembly;
|
||
Type annotationType = editorAssembly.GetType("UnityEditor.AnnotationUtility");
|
||
Type outlineModeType = editorAssembly.GetType("UnityEditor.OutlineDrawMode");
|
||
if (annotationType == null || outlineModeType == null)
|
||
throw new MissingMemberException(
|
||
"Selection annotation types are unavailable.");
|
||
|
||
int outlineMode = 0;
|
||
PropertyInfo showOutline = annotationType.GetProperty(
|
||
"showSelectionOutline", flags);
|
||
PropertyInfo showWire = annotationType.GetProperty(
|
||
"showSelectionWire", flags);
|
||
if (showOutline != null && (bool)showOutline.GetValue(null))
|
||
outlineMode |= 1;
|
||
if (showWire != null && (bool)showWire.GetValue(null))
|
||
outlineMode |= 2;
|
||
if (outlineMode == 0)
|
||
return true;
|
||
|
||
MethodInfo filterInstanceIds = typeof(HandleUtility).GetMethod(
|
||
"FilterInstanceIDs", flags);
|
||
MethodInfo drawOutline = typeof(Handles).GetMethod(
|
||
"DrawOutlineOrWireframeInternal", flags);
|
||
if (filterInstanceIds == null || drawOutline == null)
|
||
throw new MissingMethodException(
|
||
"Selection outline methods are unavailable.");
|
||
|
||
object[] filterArgs = { Selection.gameObjects, null, null };
|
||
filterInstanceIds.Invoke(null, filterArgs);
|
||
int[] parentRenderers = filterArgs[1] as int[];
|
||
int[] childRenderers = filterArgs[2] as int[];
|
||
|
||
Color parentColor = SceneView.selectedOutlineColor;
|
||
Color childColor = parentColor;
|
||
FieldInfo childColorField = typeof(SceneView).GetField(
|
||
"kSceneViewSelectedChildrenOutline", flags);
|
||
object prefColor = childColorField?.GetValue(null);
|
||
PropertyInfo colorProperty = prefColor?.GetType().GetProperty(
|
||
"Color", flags);
|
||
if (colorProperty != null)
|
||
childColor = (Color)colorProperty.GetValue(prefColor);
|
||
|
||
drawOutline.Invoke(
|
||
null,
|
||
new object[]
|
||
{
|
||
parentColor,
|
||
childColor,
|
||
1f,
|
||
parentRenderers,
|
||
childRenderers,
|
||
Enum.ToObject(outlineModeType, outlineMode),
|
||
true,
|
||
});
|
||
return true;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Exception actual =
|
||
e is TargetInvocationException && e.InnerException != null
|
||
? e.InnerException
|
||
: e;
|
||
warning =
|
||
$"Selection outline rendering was unavailable ({actual.Message}).";
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private const string CaptureSceneViewTitle = "__CodelySceneViewCapture__";
|
||
private static SceneView s_CaptureSceneView;
|
||
|
||
[InitializeOnLoadMethod]
|
||
private static void ScheduleStaleCaptureWindowCleanup()
|
||
{
|
||
AssemblyReloadEvents.beforeAssemblyReload -= CleanupRecordingBeforeDomainExit;
|
||
AssemblyReloadEvents.beforeAssemblyReload += CleanupRecordingBeforeDomainExit;
|
||
EditorApplication.quitting -= CleanupRecordingBeforeDomainExit;
|
||
EditorApplication.quitting += CleanupRecordingBeforeDomainExit;
|
||
|
||
EditorApplication.delayCall += () =>
|
||
{
|
||
foreach (SceneView view in Resources.FindObjectsOfTypeAll<SceneView>())
|
||
{
|
||
if (view != null &&
|
||
view.titleContent != null &&
|
||
view.titleContent.text == CaptureSceneViewTitle)
|
||
{
|
||
try { view.Close(); }
|
||
catch { UnityEngine.Object.DestroyImmediate(view); }
|
||
}
|
||
}
|
||
s_CaptureSceneView = null;
|
||
};
|
||
}
|
||
|
||
private static void CleanupRecordingBeforeDomainExit()
|
||
{
|
||
if (s_Mp4Recording != null)
|
||
DiscardActiveGameViewMp4Recording();
|
||
}
|
||
|
||
private static MethodInfo FindInstanceMethod(Type type, string name, int parameterCount)
|
||
{
|
||
const BindingFlags flags =
|
||
BindingFlags.Instance | BindingFlags.Public |
|
||
BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
|
||
for (Type current = type; current != null; current = current.BaseType)
|
||
{
|
||
foreach (MethodInfo method in current.GetMethods(flags))
|
||
{
|
||
if (method.Name == name &&
|
||
method.GetParameters().Length == parameterCount)
|
||
return method;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static SceneView GetOrCreateCaptureSceneView(
|
||
int width, int height, out string warning)
|
||
{
|
||
warning = null;
|
||
// Never reuse a previous capture window. In particular, older versions
|
||
// used ContainerWindow.SetInvisible(), which creates a transparent
|
||
// topmost window that still intercepts input.
|
||
if (s_CaptureSceneView != null)
|
||
{
|
||
try { s_CaptureSceneView.Close(); }
|
||
catch { UnityEngine.Object.DestroyImmediate(s_CaptureSceneView); }
|
||
s_CaptureSceneView = null;
|
||
}
|
||
|
||
foreach (SceneView existing in Resources.FindObjectsOfTypeAll<SceneView>())
|
||
{
|
||
if (existing != null &&
|
||
existing.titleContent != null &&
|
||
existing.titleContent.text == CaptureSceneViewTitle)
|
||
{
|
||
try { existing.Close(); }
|
||
catch { UnityEngine.Object.DestroyImmediate(existing); }
|
||
}
|
||
}
|
||
|
||
try
|
||
{
|
||
Assembly editorAssembly = typeof(EditorWindow).Assembly;
|
||
Type hostViewType = editorAssembly.GetType("UnityEditor.HostView");
|
||
Type containerWindowType = editorAssembly.GetType("UnityEditor.ContainerWindow");
|
||
Type showModeType = editorAssembly.GetType("UnityEditor.ShowMode");
|
||
if (hostViewType == null || containerWindowType == null ||
|
||
showModeType == null)
|
||
throw new MissingMemberException(
|
||
"Unity editor window host types are unavailable.");
|
||
|
||
SceneView view = ScriptableObject.CreateInstance<SceneView>();
|
||
view.titleContent = new GUIContent(CaptureSceneViewTitle);
|
||
view.position = new Rect(-32000f, -32000f, width, height);
|
||
view.minSize = new Vector2(64f, 64f);
|
||
|
||
ScriptableObject host =
|
||
ScriptableObject.CreateInstance(hostViewType);
|
||
ScriptableObject container =
|
||
ScriptableObject.CreateInstance(containerWindowType);
|
||
|
||
MethodInfo setActualView = FindInstanceMethod(
|
||
hostViewType, "SetActualViewInternal", 2);
|
||
PropertyInfo containerPosition = containerWindowType.GetProperty(
|
||
"position",
|
||
BindingFlags.Instance | BindingFlags.Public |
|
||
BindingFlags.NonPublic);
|
||
PropertyInfo rootView = containerWindowType.GetProperty(
|
||
"rootView",
|
||
BindingFlags.Instance | BindingFlags.Public |
|
||
BindingFlags.NonPublic);
|
||
MethodInfo makeSettings = FindInstanceMethod(
|
||
typeof(EditorWindow), "MakeParentsSettingsMatchMe", 0);
|
||
MethodInfo show = FindInstanceMethod(
|
||
containerWindowType, "Show", 4);
|
||
|
||
if (setActualView == null || containerPosition == null ||
|
||
rootView == null || makeSettings == null || show == null)
|
||
throw new MissingMemberException(
|
||
"Unity editor window host methods are unavailable.");
|
||
|
||
setActualView.Invoke(host, new object[] { view, false });
|
||
containerPosition.SetValue(
|
||
container,
|
||
new Rect(-32000f, -32000f, width, height));
|
||
rootView.SetValue(container, host);
|
||
makeSettings.Invoke(view, null);
|
||
object utilityMode = Enum.Parse(showModeType, "Utility");
|
||
show.Invoke(
|
||
container,
|
||
new object[] { utilityMode, false, false, false });
|
||
|
||
view.position =
|
||
new Rect(-32000f, -32000f, width, height);
|
||
s_CaptureSceneView = view;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Exception actual =
|
||
e is TargetInvocationException && e.InnerException != null
|
||
? e.InnerException
|
||
: e;
|
||
warning =
|
||
$"Failed to create the capture Scene View ({actual.Message}).";
|
||
return null;
|
||
}
|
||
|
||
return s_CaptureSceneView;
|
||
}
|
||
|
||
private static void CloseCaptureSceneView()
|
||
{
|
||
if (s_CaptureSceneView == null)
|
||
return;
|
||
try { s_CaptureSceneView.Close(); }
|
||
catch
|
||
{
|
||
UnityEngine.Object.DestroyImmediate(s_CaptureSceneView);
|
||
}
|
||
finally
|
||
{
|
||
s_CaptureSceneView = null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders with the real SceneView camera and Unity/Tuanjie's internal
|
||
/// editor-camera pipeline. This captures the grid, engine gizmos and
|
||
/// selection rendering without creating another Editor window.
|
||
/// </summary>
|
||
private static Texture2D RenderSceneViewCameraToTexture(
|
||
SceneView sceneView,
|
||
Camera camera,
|
||
int width,
|
||
int height,
|
||
DrawCameraMode drawMode,
|
||
Vector3 gridPivot,
|
||
out string warning)
|
||
{
|
||
warning = null;
|
||
if (sceneView == null || camera == null || width < 1 || height < 1)
|
||
return null;
|
||
|
||
// When a non-shaded render mode is requested, bypass the Tuanjie/Unity internal
|
||
// editor-camera reflection path entirely. That path passes drawMode to
|
||
// Internal_DrawCameraWithGrid/Internal_DrawCameraWithFilter but the engine may
|
||
// silently ignore it (no exception, just renders shaded anyway). The GL.wireframe-
|
||
// based RenderCameraToTexture path is renderer-level and reliably produces the
|
||
// requested mode, at the cost of losing the Scene View grid overlay and editor
|
||
// gizmos for that capture.
|
||
if (drawMode != DrawCameraMode.Textured)
|
||
return RenderCameraToTexture(camera, width, height, ToFallbackRenderMode(drawMode));
|
||
|
||
const BindingFlags instanceFlags =
|
||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||
const BindingFlags staticFlags =
|
||
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
|
||
|
||
RenderTexture previousTarget = camera.targetTexture;
|
||
RenderTexture previousActive = RenderTexture.active;
|
||
Rect previousRect = camera.rect;
|
||
RenderTexture rt = RenderTexture.GetTemporary(
|
||
width, height, 24, RenderTextureFormat.Default);
|
||
Texture2D result = null;
|
||
Exception editorRenderError = null;
|
||
bool editorCameraNeedsFinish = false;
|
||
|
||
try
|
||
{
|
||
camera.targetTexture = rt;
|
||
camera.rect = new Rect(0f, 0f, 1f, 1f);
|
||
RenderTexture.active = rt;
|
||
|
||
MethodInfo setupCamera = typeof(Handles).GetMethod(
|
||
"Internal_SetupCamera", staticFlags);
|
||
MethodInfo clearCamera = typeof(Handles).GetMethod(
|
||
"Internal_ClearCamera", staticFlags);
|
||
MethodInfo finishCamera = typeof(Handles).GetMethod(
|
||
"Internal_FinishDrawingCamera", staticFlags, null,
|
||
new[] { typeof(Camera), typeof(bool) }, null);
|
||
|
||
if (setupCamera == null || clearCamera == null ||
|
||
finishCamera == null)
|
||
throw new MissingMethodException(
|
||
"Required Handles editor-camera methods are unavailable.");
|
||
|
||
setupCamera.Invoke(null, new object[] { camera });
|
||
clearCamera.Invoke(null, new object[] { camera });
|
||
|
||
bool renderedWithGrid = false;
|
||
PropertyInfo gridsProperty = typeof(SceneView).GetProperty(
|
||
"sceneViewGrids", instanceFlags);
|
||
object grids = gridsProperty?.GetValue(sceneView);
|
||
if (grids != null)
|
||
{
|
||
MethodInfo prepareGrid = grids.GetType().GetMethod(
|
||
"PrepareGridRender", instanceFlags);
|
||
MethodInfo drawWithGrid = typeof(Handles).GetMethod(
|
||
"Internal_DrawCameraWithGrid", staticFlags);
|
||
if (prepareGrid != null && drawWithGrid != null)
|
||
{
|
||
float gridSize = camera.orthographic
|
||
? Mathf.Max(camera.orthographicSize * 2f, 0.0001f)
|
||
: sceneView.size;
|
||
object gridParams = prepareGrid.Invoke(
|
||
grids,
|
||
new object[]
|
||
{
|
||
camera,
|
||
gridPivot,
|
||
camera.transform.rotation,
|
||
gridSize,
|
||
camera.orthographic,
|
||
});
|
||
drawWithGrid.Invoke(
|
||
null,
|
||
new object[]
|
||
{
|
||
camera,
|
||
drawMode,
|
||
gridParams,
|
||
sceneView.drawGizmos,
|
||
true,
|
||
});
|
||
renderedWithGrid = true;
|
||
}
|
||
}
|
||
|
||
if (!renderedWithGrid)
|
||
{
|
||
MethodInfo drawCamera = typeof(Handles).GetMethod(
|
||
"Internal_DrawCameraWithFilter", staticFlags);
|
||
if (drawCamera == null)
|
||
throw new MissingMethodException(
|
||
"Handles.Internal_DrawCameraWithFilter is unavailable.");
|
||
drawCamera.Invoke(
|
||
null,
|
||
new object[]
|
||
{
|
||
camera,
|
||
drawMode,
|
||
sceneView.drawGizmos,
|
||
true,
|
||
null,
|
||
});
|
||
warning =
|
||
"Scene View grid API was unavailable; captured editor gizmos without the grid.";
|
||
}
|
||
|
||
string selectionWarning;
|
||
if (!TryDrawSceneViewSelection(out selectionWarning))
|
||
warning = AppendWarning(warning, selectionWarning);
|
||
|
||
editorCameraNeedsFinish = true;
|
||
finishCamera.Invoke(
|
||
null, new object[] { camera, sceneView.drawGizmos });
|
||
editorCameraNeedsFinish = false;
|
||
result = ReadRenderTexture(rt, width, height);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
editorRenderError =
|
||
e is TargetInvocationException && e.InnerException != null
|
||
? e.InnerException
|
||
: e;
|
||
}
|
||
finally
|
||
{
|
||
if (editorCameraNeedsFinish)
|
||
{
|
||
try
|
||
{
|
||
MethodInfo finishCamera = typeof(Handles).GetMethod(
|
||
"Internal_FinishDrawingCamera", staticFlags, null,
|
||
new[] { typeof(Camera), typeof(bool) }, null);
|
||
finishCamera?.Invoke(
|
||
null,
|
||
new object[] { camera, sceneView.drawGizmos });
|
||
}
|
||
catch
|
||
{
|
||
// Best-effort cleanup after a partially completed render.
|
||
}
|
||
}
|
||
|
||
camera.targetTexture = previousTarget;
|
||
camera.rect = previousRect;
|
||
RenderTexture.active = previousActive;
|
||
RenderTexture.ReleaseTemporary(rt);
|
||
}
|
||
|
||
if (result != null)
|
||
return result;
|
||
|
||
string detail =
|
||
editorRenderError?.Message ?? "unknown editor-renderer failure";
|
||
warning = AppendWarning(
|
||
warning,
|
||
$"Editor Scene View rendering was unavailable ({detail}); fell back to Camera.Render without full gizmos/grid.");
|
||
return RenderCameraToTexture(
|
||
camera, width, height, ToFallbackRenderMode(drawMode));
|
||
}
|
||
|
||
// ==================== Game View RT capture ====================
|
||
|
||
// The Game View may not repaint when the editor is unfocused. A queued Repaint is
|
||
// not sufficient in that state, so use the same synchronous native path Unity uses
|
||
// for graphics capture: update the scene, finish GPU work, then repaint the GUIView.
|
||
private static IEnumerator EnsureGameViewFresh()
|
||
{
|
||
List<EditorWindow> windows = GetPlayModeViewWindows();
|
||
EditorWindow target = windows.Count > 0 ? windows[0] : null;
|
||
if (target == null) yield break;
|
||
|
||
target.Focus();
|
||
|
||
EditorApplication.QueuePlayerLoopUpdate();
|
||
InternalEditorUtility.RepaintAllViews();
|
||
yield return null;
|
||
|
||
Canvas.ForceUpdateCanvases();
|
||
RenderGameViewImmediately(target);
|
||
yield return null;
|
||
}
|
||
|
||
private static void RenderGameViewImmediately(EditorWindow window)
|
||
{
|
||
FieldInfo parentField = typeof(EditorWindow).GetField(
|
||
"m_Parent", BindingFlags.Instance | BindingFlags.NonPublic);
|
||
object guiView = parentField?.GetValue(window);
|
||
if (guiView == null)
|
||
throw new InvalidOperationException("Game View has no GUIView parent.");
|
||
|
||
MethodInfo renderScene = FindInstanceMethod(
|
||
guiView.GetType(), "RenderCurrentSceneForCapture", 0);
|
||
MethodInfo repaintImmediately = FindInstanceMethod(
|
||
window.GetType(), "RepaintImmediately", 0);
|
||
if (renderScene == null || repaintImmediately == null)
|
||
throw new MissingMethodException(
|
||
"GUIView capture/repaint methods are unavailable in this editor version.");
|
||
|
||
renderScene.Invoke(guiView, null);
|
||
// Invoke through EditorWindow so Unity verifies that this window is the
|
||
// HostView's actual selected view before forwarding to GUIView.DoPaint().
|
||
repaintImmediately.Invoke(window, null);
|
||
}
|
||
|
||
private static Texture2D CaptureGameViewTexture()
|
||
{
|
||
// Primary path plus fallbacks: read a play-view window's internal RenderTexture
|
||
// via reflection. We probe every play-mode view (Game View, Device Simulator, HMI
|
||
// Simulator, …) most-likely-live first; the first source that yields a non-black
|
||
// frame wins. A black capture is discarded in favor of the next source, and if
|
||
// every source is black we still return the first frame we got.
|
||
try
|
||
{
|
||
Texture2D firstCapture = null; // first non-null (possibly black) capture, kept as last resort
|
||
List<EditorWindow> windows = GetPlayModeViewWindows();
|
||
if (windows.Count == 0)
|
||
CodelyLogger.LogWarning("[ManageScreenshot] No PlayModeView windows found to capture.");
|
||
|
||
foreach (EditorWindow window in windows)
|
||
{
|
||
if (window == null) continue;
|
||
RenderTexture srcRT = GetGameViewRT(window);
|
||
Texture2D tex = CaptureWindowRT(window);
|
||
bool black = tex == null || IsTextureBlack(tex);
|
||
string rtInfo = srcRT == null
|
||
? "null"
|
||
: $"{srcRT.width}x{srcRT.height} {srcRT.format} created={srcRT.IsCreated()}";
|
||
string outcome = tex == null ? "no-texture" : (black ? "black" : "FRAME");
|
||
CodelyLogger.Log($"[ManageScreenshot] probe {window.GetType().FullName}: rt={rtInfo} -> {outcome}");
|
||
if (tex == null) continue;
|
||
if (!black) return tex; // a real frame — done
|
||
if (firstCapture == null) firstCapture = tex; // remember, keep probing later sources
|
||
else UnityEngine.Object.DestroyImmediate(tex);
|
||
}
|
||
|
||
if (firstCapture != null) return firstCapture;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
CodelyLogger.LogWarning($"[ManageScreenshot] GameView RT reflection failed: {e.Message}. Falling back to camera rendering.");
|
||
}
|
||
|
||
// Fallback: render all active cameras using the actual Game View size
|
||
Vector2 gameViewSize = Handles.GetMainGameViewSize();
|
||
int fw = Mathf.Max((int)gameViewSize.x, 1);
|
||
int fh = Mathf.Max((int)gameViewSize.y, 1);
|
||
|
||
return RenderAllCamerasToTexture(fw, fh);
|
||
}
|
||
|
||
// Blits a play-view window's internal RenderTexture (Game View / Simulator /
|
||
// HMISimulator) into a CPU-side, vertically-corrected Texture2D. Returns null when
|
||
// the window is null or exposes no readable render texture.
|
||
private static Texture2D CaptureWindowRT(EditorWindow window)
|
||
{
|
||
if (window == null) return null;
|
||
RenderTexture rt = GetGameViewRT(window);
|
||
if (rt == null || rt.width <= 0 || rt.height <= 0) return null;
|
||
|
||
int w = rt.width;
|
||
int h = rt.height;
|
||
RenderTexture tmp = RenderTexture.GetTemporary(w, h, 0, rt.format);
|
||
Graphics.Blit(rt, tmp);
|
||
Texture2D tex = ReadRenderTexture(tmp, w, h);
|
||
RenderTexture.ReleaseTemporary(tmp);
|
||
if (tex != null) FlipTextureVertically(tex);
|
||
return tex;
|
||
}
|
||
|
||
|
||
// CPU-side companion to IsRenderTextureBlack (which is gated to newer Unity and reads
|
||
// from a RenderTexture). Samples a center block of an already-read texture and reports
|
||
// true when every sampled pixel is (near-)black. Alpha is ignored so an opaque black
|
||
// frame — the usual "nothing rendered" case for a play view — counts as black.
|
||
private static bool IsTextureBlack(Texture2D tex)
|
||
{
|
||
if (tex == null) return true;
|
||
int sampleW = Mathf.Min(32, tex.width);
|
||
int sampleH = Mathf.Min(32, tex.height);
|
||
if (sampleW <= 0 || sampleH <= 0) return true;
|
||
int startX = (tex.width - sampleW) / 2;
|
||
int startY = (tex.height - sampleH) / 2;
|
||
|
||
Color[] block = tex.GetPixels(startX, startY, sampleW, sampleH);
|
||
foreach (Color c in block)
|
||
if (c.r > 0.01f || c.g > 0.01f || c.b > 0.01f)
|
||
return false;
|
||
return true;
|
||
}
|
||
|
||
// Returns the play-mode view windows to probe for a rendered frame.
|
||
//
|
||
// Discovery is by base-type NAME, not by a resolved Type. The Device/HMI simulators are
|
||
// built into separate editor module assemblies (UnityEditor.DeviceSimulatorModule /
|
||
// UnityEditor.HMISimulatorModule) whose assembly-qualified names differ across
|
||
// Unity/Tuanjie versions, so Type.GetType("…,UnityEditor") returns null for them and
|
||
// they get skipped — the original bug. Instead we enumerate every loaded EditorWindow
|
||
// and keep the ones whose base-type chain contains UnityEditor.PlayModeView (Game View,
|
||
// Device Simulator, HMI Simulator, …), regardless of which assembly each lives in.
|
||
//
|
||
// Ordering puts the most-likely-live source first: the focused window, then simulators
|
||
// ahead of the plain Game View (so an active simulated frame wins over a stale Game
|
||
// View RT). The caller still discards any black frame in favor of the next source.
|
||
private static List<EditorWindow> GetPlayModeViewWindows()
|
||
{
|
||
var result = new List<EditorWindow>();
|
||
|
||
EditorWindow[] all = Resources.FindObjectsOfTypeAll<EditorWindow>();
|
||
foreach (EditorWindow w in all)
|
||
if (w != null && IsPlayModeView(w.GetType()))
|
||
result.Add(w);
|
||
|
||
EditorWindow focused = EditorWindow.focusedWindow;
|
||
result.Sort((a, b) => RankPlayModeView(a, focused).CompareTo(RankPlayModeView(b, focused)));
|
||
return result;
|
||
}
|
||
|
||
// True when any type in the chain is the internal UnityEditor.PlayModeView base class.
|
||
private static bool IsPlayModeView(Type t)
|
||
{
|
||
for (; t != null && t != typeof(object); t = t.BaseType)
|
||
if (t.Name == "PlayModeView")
|
||
return true;
|
||
return false;
|
||
}
|
||
|
||
// Lower rank = probed earlier. Focused window first, then simulators, then Game View.
|
||
private static int RankPlayModeView(EditorWindow w, EditorWindow focused)
|
||
{
|
||
if (w == focused) return 0;
|
||
string n = w.GetType().Name;
|
||
if (n.IndexOf("Simulator", StringComparison.OrdinalIgnoreCase) >= 0) return 1;
|
||
if (n.IndexOf("GameView", StringComparison.OrdinalIgnoreCase) >= 0) return 2;
|
||
return 3;
|
||
}
|
||
|
||
// Reads the RenderTexture a play-mode view last rendered into. Two storage schemes exist:
|
||
// GameView -> its own private "m_RenderTexture"
|
||
// Device SimulatorWindow -> base PlayModeView's private "m_TargetTexture"
|
||
// HMI SimulatorWindow -> NEITHER (Tuanjie only). It bypasses base RenderView() and
|
||
// renders each screen into its DeviceView.PreviewTexture,
|
||
// leaving m_TargetTexture null.
|
||
// So we first try the RT fields (walking the type hierarchy, because PlayModeView's
|
||
// m_TargetTexture is a *private base-class* field that GetField won't surface from the
|
||
// derived type without DeclaredOnly + climbing BaseType). If that yields nothing — the
|
||
// HMI case, compiled only on Tuanjie — we walk the window's visual tree for an element
|
||
// exposing a RenderTexture "PreviewTexture", which is how the simulators publish frames.
|
||
private static RenderTexture GetGameViewRT(EditorWindow gv)
|
||
{
|
||
if (gv == null) return null;
|
||
const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Public |
|
||
BindingFlags.Instance | BindingFlags.DeclaredOnly;
|
||
string[] candidates = { "targetTexture", "m_TargetTexture", "m_RenderTexture" };
|
||
|
||
foreach (string name in candidates)
|
||
{
|
||
for (Type t = gv.GetType(); t != null && t != typeof(object); t = t.BaseType)
|
||
{
|
||
PropertyInfo pi = t.GetProperty(name, flags);
|
||
if (pi != null && pi.GetValue(gv) is RenderTexture prt && prt.IsCreated()) return prt;
|
||
|
||
FieldInfo fi = t.GetField(name, flags);
|
||
if (fi != null && fi.GetValue(gv) is RenderTexture frt && frt.IsCreated()) return frt;
|
||
}
|
||
}
|
||
|
||
#if TUANJIE_1_0_OR_NEWER
|
||
// HMI Simulator is a Tuanjie-only window that renders into DeviceView.PreviewTexture
|
||
// rather than the base PlayModeView render texture, so reach into its visual tree.
|
||
// Tuanjie is 2022.3-based, so UNITY_2021_2_OR_NEWER (UIElements `using`) always holds.
|
||
RenderTexture preview = FindPreviewTextureInVisualTree(gv.rootVisualElement);
|
||
if (preview != null) return preview;
|
||
#endif
|
||
return null;
|
||
}
|
||
|
||
#if TUANJIE_1_0_OR_NEWER
|
||
// Depth-first search of a window's UI Toolkit tree for an element that publishes its
|
||
// rendered frame through a "RenderTexture PreviewTexture" property (both the Device and
|
||
// HMI simulators' DeviceView do this). Used for the Tuanjie-only HMI Simulator, whose
|
||
// frame never reaches the base PlayModeView render texture. Reflection keeps us
|
||
// decoupled from the simulators' internal, separately-compiled DeviceView types.
|
||
private static RenderTexture FindPreviewTextureInVisualTree(VisualElement element)
|
||
{
|
||
if (element == null) return null;
|
||
|
||
PropertyInfo pi = element.GetType().GetProperty("PreviewTexture",
|
||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||
if (pi != null && pi.PropertyType == typeof(RenderTexture)
|
||
&& pi.GetValue(element) is RenderTexture rt && rt.IsCreated())
|
||
return rt;
|
||
|
||
int childCount = element.hierarchy.childCount;
|
||
for (int i = 0; i < childCount; i++)
|
||
{
|
||
RenderTexture found = FindPreviewTextureInVisualTree(element.hierarchy[i]);
|
||
if (found != null) return found;
|
||
}
|
||
return null;
|
||
}
|
||
#endif
|
||
|
||
private static Texture2D RenderAllCamerasToTexture(int width, int height)
|
||
{
|
||
RenderTexture rt = new RenderTexture(width, height, 24);
|
||
RenderTexture prevActive = RenderTexture.active;
|
||
RenderTexture.active = rt;
|
||
GL.Clear(true, true, Color.black);
|
||
|
||
Camera[] cameras = Camera.allCameras;
|
||
System.Array.Sort(cameras, (a, b) => a.depth.CompareTo(b.depth));
|
||
|
||
foreach (Camera cam in cameras)
|
||
{
|
||
if (cam == null || !cam.enabled || !cam.gameObject.activeInHierarchy) continue;
|
||
RenderTexture prev = cam.targetTexture;
|
||
cam.targetTexture = rt;
|
||
cam.Render();
|
||
cam.targetTexture = prev;
|
||
}
|
||
|
||
Texture2D tex = ReadRenderTexture(rt, width, height);
|
||
RenderTexture.active = prevActive;
|
||
UnityEngine.Object.DestroyImmediate(rt);
|
||
return tex;
|
||
}
|
||
|
||
// ==================== Camera helpers ====================
|
||
|
||
private static Texture2D RenderCameraToTexture(Camera cam, int width, int height)
|
||
{
|
||
return RenderCameraToTexture(cam, width, height, CaptureRenderMode.Shaded);
|
||
}
|
||
|
||
// How geometry is drawn when rendering a camera to a texture.
|
||
// Shaded – normal lit/textured render (default).
|
||
// Wireframe – wireframe only (GL.wireframe during the render pass).
|
||
// ShadedWireframe – a shaded render with the wireframe edges composited on top.
|
||
private enum CaptureRenderMode { Shaded, Wireframe, ShadedWireframe }
|
||
|
||
private static CaptureRenderMode ParseRenderMode(JToken token)
|
||
{
|
||
string raw = token?.ToString()?.Trim().ToLower().Replace("_", "").Replace("-", "");
|
||
switch (raw)
|
||
{
|
||
case "wireframe": return CaptureRenderMode.Wireframe;
|
||
case "shadedwireframe": return CaptureRenderMode.ShadedWireframe;
|
||
default: return CaptureRenderMode.Shaded;
|
||
}
|
||
}
|
||
|
||
private static Texture2D RenderCameraToTexture(Camera cam, int width, int height, CaptureRenderMode renderMode)
|
||
{
|
||
if (cam == null) return null;
|
||
|
||
if (renderMode == CaptureRenderMode.ShadedWireframe)
|
||
return RenderShadedWireframe(cam, width, height);
|
||
|
||
RenderTexture rt = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.Default);
|
||
RenderTexture prev = cam.targetTexture;
|
||
cam.targetTexture = rt;
|
||
|
||
// GL.wireframe only affects rendering issued while it is true, and must be reset
|
||
// afterwards so it doesn't leak into unrelated editor/scene-view drawing.
|
||
bool prevWireframe = GL.wireframe;
|
||
try
|
||
{
|
||
GL.wireframe = renderMode == CaptureRenderMode.Wireframe;
|
||
cam.Render();
|
||
}
|
||
finally
|
||
{
|
||
GL.wireframe = prevWireframe;
|
||
cam.targetTexture = prev;
|
||
}
|
||
|
||
Texture2D tex = ReadRenderTexture(rt, width, height);
|
||
RenderTexture.ReleaseTemporary(rt);
|
||
return tex;
|
||
}
|
||
|
||
// Shaded surfaces with the wireframe edges drawn on top. Composited at the texture
|
||
// level so it is independent of render-pipeline / clear-flag behavior (an in-place
|
||
// second pass with clearFlags=Nothing is not honored under SRPs, which is why it
|
||
// previously looked identical to plain wireframe):
|
||
// 1. a normal shaded pass;
|
||
// 2. two wireframe passes over solid black and solid white backgrounds.
|
||
// Opaque edge pixels render identically over either background, while untouched
|
||
// background pixels differ wildly — that agreement is a reliable wireframe mask that
|
||
// does not depend on the geometry's material color. Mask pixels darken the shaded
|
||
// image to read as edges.
|
||
private static Texture2D RenderShadedWireframe(Camera cam, int width, int height)
|
||
{
|
||
RenderTexture rt = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.Default);
|
||
RenderTexture prev = cam.targetTexture;
|
||
bool prevWireframe = GL.wireframe;
|
||
CameraClearFlags origFlags = cam.clearFlags;
|
||
Color origBg = cam.backgroundColor;
|
||
|
||
Color32[] shaded, wireBlack, wireWhite;
|
||
try
|
||
{
|
||
cam.targetTexture = rt;
|
||
|
||
// Shaded pass (keep the camera's own clear flags / background).
|
||
GL.wireframe = false;
|
||
cam.Render();
|
||
shaded = ReadPixels32(rt, width, height);
|
||
|
||
// Wireframe passes over two solid backgrounds.
|
||
GL.wireframe = true;
|
||
cam.clearFlags = CameraClearFlags.SolidColor;
|
||
|
||
cam.backgroundColor = Color.black;
|
||
cam.Render();
|
||
wireBlack = ReadPixels32(rt, width, height);
|
||
|
||
cam.backgroundColor = Color.white;
|
||
cam.Render();
|
||
wireWhite = ReadPixels32(rt, width, height);
|
||
}
|
||
finally
|
||
{
|
||
GL.wireframe = prevWireframe;
|
||
cam.clearFlags = origFlags;
|
||
cam.backgroundColor = origBg;
|
||
cam.targetTexture = prev;
|
||
RenderTexture.ReleaseTemporary(rt);
|
||
}
|
||
|
||
for (int i = 0; i < shaded.Length; i++)
|
||
{
|
||
int diff = Math.Abs(wireBlack[i].r - wireWhite[i].r)
|
||
+ Math.Abs(wireBlack[i].g - wireWhite[i].g)
|
||
+ Math.Abs(wireBlack[i].b - wireWhite[i].b);
|
||
if (diff < 24) // drawn edge (same regardless of background) → darken to a line
|
||
shaded[i] = new Color32((byte)(shaded[i].r / 4), (byte)(shaded[i].g / 4), (byte)(shaded[i].b / 4), 255);
|
||
}
|
||
|
||
Texture2D tex = new Texture2D(width, height, TextureFormat.ARGB32, false);
|
||
tex.SetPixels32(shaded);
|
||
tex.Apply();
|
||
return tex;
|
||
}
|
||
|
||
// Reads the active contents of a RenderTexture into a Color32[] (top-up Unity order).
|
||
private static Color32[] ReadPixels32(RenderTexture rt, int width, int height)
|
||
{
|
||
RenderTexture prevActive = RenderTexture.active;
|
||
RenderTexture.active = rt;
|
||
Texture2D tmp = new Texture2D(width, height, TextureFormat.ARGB32, false);
|
||
tmp.ReadPixels(new Rect(0, 0, width, height), 0, 0);
|
||
tmp.Apply();
|
||
RenderTexture.active = prevActive;
|
||
Color32[] px = tmp.GetPixels32();
|
||
UnityEngine.Object.DestroyImmediate(tmp);
|
||
return px;
|
||
}
|
||
|
||
private static Texture2D ReadRenderTexture(RenderTexture rt, int width, int height)
|
||
{
|
||
if (rt == null) return null;
|
||
RenderTexture prevActive = RenderTexture.active;
|
||
RenderTexture.active = rt;
|
||
Texture2D tex = new Texture2D(width, height, TextureFormat.ARGB32, false);
|
||
tex.ReadPixels(new Rect(0, 0, width, height), 0, 0);
|
||
tex.Apply();
|
||
RenderTexture.active = prevActive;
|
||
return tex;
|
||
}
|
||
|
||
private static void CopyCameraSettings(Camera src, Camera dst)
|
||
{
|
||
dst.clearFlags = src.clearFlags;
|
||
dst.backgroundColor = src.backgroundColor;
|
||
dst.cullingMask = src.cullingMask;
|
||
dst.nearClipPlane = src.nearClipPlane;
|
||
dst.farClipPlane = src.farClipPlane;
|
||
dst.fieldOfView = src.fieldOfView;
|
||
dst.renderingPath = src.renderingPath;
|
||
dst.allowHDR = src.allowHDR;
|
||
dst.allowMSAA = src.allowMSAA;
|
||
}
|
||
|
||
// ==================== Asset instantiation ====================
|
||
|
||
private static GameObject InstantiateAsset(string relPath, Type assetType)
|
||
{
|
||
if (assetType == typeof(GameObject))
|
||
{
|
||
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(relPath);
|
||
if (prefab == null) return null;
|
||
return (GameObject)PrefabUtility.InstantiatePrefab(prefab);
|
||
}
|
||
|
||
if (assetType == typeof(Mesh))
|
||
{
|
||
Mesh mesh = AssetDatabase.LoadAssetAtPath<Mesh>(relPath);
|
||
if (mesh == null) return null;
|
||
GameObject go = GameObject.CreatePrimitive(PrimitiveType.Quad);
|
||
go.GetComponent<MeshFilter>().sharedMesh = mesh;
|
||
return go;
|
||
}
|
||
|
||
if (assetType == typeof(Material))
|
||
{
|
||
Material mat = AssetDatabase.LoadAssetAtPath<Material>(relPath);
|
||
if (mat == null) return null;
|
||
GameObject go = GameObject.CreatePrimitive(PrimitiveType.Sphere);
|
||
go.transform.localScale = Vector3.one * 3f;
|
||
go.GetComponent<Renderer>().sharedMaterial = mat;
|
||
return go;
|
||
}
|
||
|
||
if (assetType == typeof(Texture2D))
|
||
{
|
||
// Try as sprite first; fall back to a textured quad
|
||
Sprite sprite = AssetDatabase.LoadAssetAtPath<Sprite>(relPath);
|
||
if (sprite != null)
|
||
{
|
||
GameObject go = new GameObject("SpritePreview");
|
||
go.AddComponent<SpriteRenderer>().sprite = sprite;
|
||
return go;
|
||
}
|
||
|
||
Texture2D tex = AssetDatabase.LoadAssetAtPath<Texture2D>(relPath);
|
||
if (tex == null) return null;
|
||
GameObject quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
|
||
Material mat = new Material(Shader.Find("Unlit/Texture")) { mainTexture = tex };
|
||
quad.GetComponent<Renderer>().sharedMaterial = mat;
|
||
return quad;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ==================== Over-time helpers ====================
|
||
|
||
private struct OverTimeParams
|
||
{
|
||
/// <summary>Total number of frames to capture.</summary>
|
||
public int Count;
|
||
/// <summary>
|
||
/// How many simulation steps to advance between captures.
|
||
/// 0 means no stepping (captures taken at same instant).
|
||
/// </summary>
|
||
public int StepsPerInterval;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parses the <c>over_time</c> parameter.
|
||
/// Accepted formats:
|
||
/// "N" – capture N frames with no simulation stepping.
|
||
/// "NxS" – capture N frames, stepping S simulation frames between each.
|
||
/// Examples: "4", "4x1", "6x3"
|
||
/// </summary>
|
||
private static OverTimeParams ParseOverTime(JToken token)
|
||
{
|
||
if (token == null) return new OverTimeParams { Count = 1, StepsPerInterval = 0 };
|
||
|
||
string raw = token.ToString().Trim();
|
||
if (string.IsNullOrEmpty(raw)) return new OverTimeParams { Count = 1, StepsPerInterval = 0 };
|
||
|
||
int separatorIdx = raw.IndexOf('x');
|
||
if (separatorIdx < 0)
|
||
{
|
||
int.TryParse(raw, out int count);
|
||
return new OverTimeParams { Count = Math.Max(1, count), StepsPerInterval = 0 };
|
||
}
|
||
else
|
||
{
|
||
int.TryParse(raw.Substring(0, separatorIdx), out int count);
|
||
int.TryParse(raw.Substring(separatorIdx + 1), out int steps);
|
||
return new OverTimeParams
|
||
{
|
||
Count = Math.Max(1, count),
|
||
StepsPerInterval = Math.Max(0, steps),
|
||
};
|
||
}
|
||
}
|
||
|
||
|
||
/// <summary>Short uppercase label for a zero-based frame index ("F0", "F1", …).</summary>
|
||
private static string FrameLabel(int frameIndex) => "FRAME" + frameIndex;
|
||
|
||
// ==================== View direction / framing ====================
|
||
|
||
/// <summary>Expands "all" / "cardinal" shorthands into a list of named directions.</summary>
|
||
private static List<string> GetViewList(string viewParam)
|
||
{
|
||
switch (viewParam)
|
||
{
|
||
case "all": return new List<string> { "front", "back", "left", "right", "top", "bottom", "iso" };
|
||
case "cardinal": return new List<string> { "front", "right", "top", "iso" };
|
||
default: return new List<string> { string.IsNullOrEmpty(viewParam) ? "current" : viewParam };
|
||
}
|
||
}
|
||
|
||
private static string ViewAbbreviation(string view)
|
||
{
|
||
switch (view)
|
||
{
|
||
case "front": return "FRONT";
|
||
case "back": return "BACK";
|
||
case "left": return "LEFT";
|
||
case "right": return "RIGHT";
|
||
case "top": return "TOP";
|
||
case "bottom": return "BOTTOM";
|
||
case "iso": return "ISO";
|
||
default: return view.ToUpper();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Positions and orients <paramref name="cam"/> so that <paramref name="bounds"/> fills
|
||
/// the view from the given named direction, with a 20% padding margin.
|
||
/// </summary>
|
||
private static void PositionCameraForView(Camera cam, Bounds bounds, string view, bool orthographic)
|
||
{
|
||
|
||
|
||
Vector3 center = bounds.center;
|
||
Vector3 dir, up;
|
||
|
||
switch (view)
|
||
{
|
||
case "front": dir = Vector3.back; up = Vector3.up; break;
|
||
case "back": dir = Vector3.forward; up = Vector3.up; break;
|
||
case "right": dir = Vector3.left; up = Vector3.up; break;
|
||
case "left": dir = Vector3.right; up = Vector3.up; break;
|
||
case "top": dir = Vector3.down; up = Vector3.forward; break;
|
||
case "bottom": dir = Vector3.up; up = Vector3.back; break;
|
||
case "iso": dir = new Vector3(-1, -1, -1).normalized; up = Vector3.up;
|
||
orthographic = true;
|
||
break;
|
||
default: return; // "current" — caller is responsible for positioning
|
||
}
|
||
|
||
float maxExtent = Mathf.Max(bounds.extents.x, bounds.extents.y, bounds.extents.z);
|
||
const float padding = 2f;
|
||
|
||
if (orthographic)
|
||
{
|
||
cam.orthographic = orthographic;
|
||
cam.orthographicSize = maxExtent * padding;
|
||
float dist = maxExtent * 3f + cam.nearClipPlane;
|
||
cam.transform.position = center - dir * dist;
|
||
}
|
||
else
|
||
{
|
||
float fovRad = cam.fieldOfView * Mathf.Deg2Rad;
|
||
float dist = (maxExtent * padding) / Mathf.Tan(fovRad * 0.5f);
|
||
dist = Mathf.Max(dist, cam.nearClipPlane + 0.1f);
|
||
cam.transform.position = center - dir * dist;
|
||
}
|
||
|
||
cam.transform.rotation = Quaternion.LookRotation(dir, up);
|
||
}
|
||
|
||
private static Bounds GetSceneBounds()
|
||
{
|
||
#if UNITY_6000_5_OR_NEWER
|
||
Renderer[] renderers = UnityEngine.Object.FindObjectsByType<Renderer>();
|
||
#elif UNITY_2022_2_OR_NEWER
|
||
Renderer[] renderers = UnityEngine.Object.FindObjectsByType<Renderer>(FindObjectsSortMode.None);
|
||
#else
|
||
Renderer[] renderers = UnityEngine.Object.FindObjectsOfType<Renderer>();
|
||
#endif
|
||
if (renderers.Length > 0)
|
||
{
|
||
Bounds b = renderers[0].bounds;
|
||
foreach (Renderer r in renderers)
|
||
b.Encapsulate(r.bounds);
|
||
return b;
|
||
}
|
||
return new Bounds(Vector3.zero, Vector3.one * 10f);
|
||
}
|
||
|
||
private static Bounds CalculateBounds(List<GameObject> objects)
|
||
{
|
||
if (objects == null || objects.Count == 0)
|
||
return new Bounds(Vector3.zero, Vector3.one);
|
||
|
||
Bounds b = new Bounds(objects[0].transform.position, Vector3.zero);
|
||
foreach (GameObject go in objects)
|
||
foreach (Renderer r in go.GetComponentsInChildren<Renderer>())
|
||
b.Encapsulate(r.bounds);
|
||
return b;
|
||
}
|
||
|
||
private static Bounds CalculateSingleObjectBounds(GameObject go)
|
||
{
|
||
Bounds b = new Bounds(go.transform.position, Vector3.zero);
|
||
foreach (Renderer r in go.GetComponentsInChildren<Renderer>())
|
||
b.Encapsulate(r.bounds);
|
||
// Ensure non-zero extents for objects without renderers (e.g. empty prefabs)
|
||
if (b.size == Vector3.zero)
|
||
b.size = Vector3.one;
|
||
return b;
|
||
}
|
||
|
||
// ==================== GameObject path lookup ====================
|
||
|
||
/// <summary>
|
||
/// Finds a GameObject in the active scene by hierarchy path.
|
||
/// Path segments are separated by "/" with no index notation in the string.
|
||
/// <paramref name="targetIndex"/> selects the nth same-named sibling only at the
|
||
/// final path segment (0-based, default 0). Intermediate segments always resolve
|
||
/// to the first matching child.
|
||
/// Examples:
|
||
/// FindGameObjectByPath("Player", 0) – first root named "Player"
|
||
/// FindGameObjectByPath("Root/Enemies/Boss") – nested, first match each level
|
||
/// FindGameObjectByPath("Root/Bullet", 2) – 3rd child of Root named "Bullet"
|
||
/// Returns null if any segment cannot be resolved.
|
||
/// </summary>
|
||
private static GameObject FindGameObjectByPath(string path, int targetIndex = 0)
|
||
{
|
||
if (string.IsNullOrEmpty(path)) return null;
|
||
|
||
path = path.Replace('\\', '/').TrimStart('/');
|
||
|
||
// Fast path: index 0 is handled directly by the built-in method which
|
||
// supports "/" separated hierarchy paths and returns the first match.
|
||
if (targetIndex == 0)
|
||
return GameObject.Find(path);
|
||
|
||
// index > 0: find the parent via built-in, then pick the nth same-named
|
||
// sibling at the final segment manually.
|
||
int lastSlash = path.LastIndexOf('/');
|
||
string finalName = lastSlash < 0 ? path : path.Substring(lastSlash + 1);
|
||
|
||
// Collect candidates at the final level
|
||
List<Transform> candidates = new List<Transform>();
|
||
if (lastSlash < 0)
|
||
{
|
||
// Root-level objects
|
||
foreach (var r in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects())
|
||
if (r.name == finalName) candidates.Add(r.transform);
|
||
}
|
||
else
|
||
{
|
||
string parentPath = path.Substring(0, lastSlash);
|
||
GameObject parent = GameObject.Find(parentPath);
|
||
if (parent == null) return null;
|
||
foreach (Transform child in parent.transform)
|
||
if (child.name == finalName) candidates.Add(child);
|
||
}
|
||
|
||
return targetIndex < candidates.Count ? candidates[targetIndex].gameObject : null;
|
||
}
|
||
|
||
// ==================== Image encoding / saving ====================
|
||
|
||
private static object BuildImageResponse(
|
||
Texture2D tex,
|
||
string customPath, string customFilename,
|
||
string prefix, string warning = null)
|
||
{
|
||
try
|
||
{
|
||
byte[] bytes = tex.EncodeToPNG();
|
||
|
||
string fn = !string.IsNullOrEmpty(customFilename)
|
||
? (customFilename.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
|
||
? customFilename
|
||
: customFilename + ".png")
|
||
: $"{prefix}_{Timestamp()}.png";
|
||
|
||
string dir = !string.IsNullOrEmpty(customPath)
|
||
? customPath
|
||
: Path.Combine(Path.GetDirectoryName(Application.dataPath), "screenshots");
|
||
|
||
Directory.CreateDirectory(dir);
|
||
string savePath = Path.Combine(dir, fn);
|
||
File.WriteAllBytes(savePath, bytes);
|
||
CodelyLogger.Log($"[ManageScreenshot] Saved: {savePath}");
|
||
|
||
int tw = tex.width, th = tex.height;
|
||
UnityEngine.Object.DestroyImmediate(tex);
|
||
return Response.Success("Screenshot captured successfully.", new
|
||
{
|
||
path = savePath,
|
||
width = tw,
|
||
height = th,
|
||
warning = string.IsNullOrEmpty(warning) ? null : warning,
|
||
});
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
if (tex != null) UnityEngine.Object.DestroyImmediate(tex);
|
||
return Response.Error($"Failed to encode/save screenshot: {e.Message}");
|
||
}
|
||
}
|
||
|
||
// ==================== Texture utilities ====================
|
||
|
||
// When neither width nor height is supplied by the caller, captured textures
|
||
// are downsampled so their longest edge equals this value.
|
||
private const int AutoScaleMaxEdge = 960;
|
||
|
||
private static Texture2D MaybeAutoScale(JObject p, Texture2D tex)
|
||
{
|
||
if (tex == null) return tex;
|
||
int? w = p?["width"]?.ToObject<int?>();
|
||
int? h = p?["height"]?.ToObject<int?>();
|
||
if (w != null || h != null) return tex;
|
||
return ScaleTextureToMaxEdge(tex, AutoScaleMaxEdge);
|
||
}
|
||
|
||
private static Texture2D ScaleTextureToMaxEdge(Texture2D src, int maxEdge)
|
||
{
|
||
if (src == null) return null;
|
||
int srcW = src.width;
|
||
int srcH = src.height;
|
||
int longest = Mathf.Max(srcW, srcH);
|
||
if (longest <= maxEdge) return src;
|
||
|
||
float scale = (float)maxEdge / longest;
|
||
int dstW = Mathf.Max(1, Mathf.RoundToInt(srcW * scale));
|
||
int dstH = Mathf.Max(1, Mathf.RoundToInt(srcH * scale));
|
||
|
||
RenderTexture rt = RenderTexture.GetTemporary(dstW, dstH, 0, RenderTextureFormat.ARGB32);
|
||
rt.filterMode = FilterMode.Bilinear;
|
||
FilterMode prevFilter = src.filterMode;
|
||
src.filterMode = FilterMode.Bilinear;
|
||
RenderTexture prevActive = RenderTexture.active;
|
||
|
||
Graphics.Blit(src, rt);
|
||
RenderTexture.active = rt;
|
||
Texture2D scaled = new Texture2D(dstW, dstH, TextureFormat.ARGB32, false);
|
||
scaled.ReadPixels(new Rect(0, 0, dstW, dstH), 0, 0);
|
||
scaled.Apply();
|
||
|
||
RenderTexture.active = prevActive;
|
||
RenderTexture.ReleaseTemporary(rt);
|
||
src.filterMode = prevFilter;
|
||
UnityEngine.Object.DestroyImmediate(src);
|
||
return scaled;
|
||
}
|
||
|
||
// Downscales (or returns unchanged) a texture by a uniform 0..1 factor. Destroys the
|
||
// source and returns a new texture when scaling is applied; returns the source as-is
|
||
// when factor is effectively 1.0.
|
||
private static Texture2D ScaleTextureByFactor(Texture2D src, float factor)
|
||
{
|
||
if (src == null) return null;
|
||
if (factor >= 0.999f) return src;
|
||
|
||
int dstW = Mathf.Max(1, Mathf.RoundToInt(src.width * factor));
|
||
int dstH = Mathf.Max(1, Mathf.RoundToInt(src.height * factor));
|
||
|
||
RenderTexture rt = RenderTexture.GetTemporary(dstW, dstH, 0, RenderTextureFormat.ARGB32);
|
||
rt.filterMode = FilterMode.Bilinear;
|
||
FilterMode prevFilter = src.filterMode;
|
||
src.filterMode = FilterMode.Bilinear;
|
||
RenderTexture prevActive = RenderTexture.active;
|
||
|
||
Graphics.Blit(src, rt);
|
||
RenderTexture.active = rt;
|
||
Texture2D scaled = new Texture2D(dstW, dstH, TextureFormat.ARGB32, false);
|
||
scaled.ReadPixels(new Rect(0, 0, dstW, dstH), 0, 0);
|
||
scaled.Apply();
|
||
|
||
RenderTexture.active = prevActive;
|
||
RenderTexture.ReleaseTemporary(rt);
|
||
src.filterMode = prevFilter;
|
||
UnityEngine.Object.DestroyImmediate(src);
|
||
return scaled;
|
||
}
|
||
|
||
private static Texture2D PrepareMp4Frame(Texture2D src)
|
||
{
|
||
if (src == null) return null;
|
||
int dstW = Mathf.Max(2, src.width - src.width % 2);
|
||
int dstH = Mathf.Max(2, src.height - src.height % 2);
|
||
if (dstW == src.width && dstH == src.height &&
|
||
src.format == TextureFormat.RGBA32)
|
||
return src;
|
||
|
||
RenderTexture rt = RenderTexture.GetTemporary(
|
||
dstW, dstH, 0, RenderTextureFormat.ARGB32);
|
||
RenderTexture previous = RenderTexture.active;
|
||
try
|
||
{
|
||
Graphics.Blit(src, rt);
|
||
RenderTexture.active = rt;
|
||
var resized = new Texture2D(dstW, dstH, TextureFormat.RGBA32, false);
|
||
resized.ReadPixels(new Rect(0, 0, dstW, dstH), 0, 0);
|
||
resized.Apply();
|
||
UnityEngine.Object.DestroyImmediate(src);
|
||
return resized;
|
||
}
|
||
finally
|
||
{
|
||
RenderTexture.active = previous;
|
||
RenderTexture.ReleaseTemporary(rt);
|
||
}
|
||
}
|
||
|
||
private static void FlipTextureVertically(Texture2D tex)
|
||
{
|
||
if (tex == null || tex.height <= 1) return;
|
||
int w = tex.width, h = tex.height;
|
||
Color[] pixels = tex.GetPixels();
|
||
for (int y = 0; y < h / 2; y++)
|
||
{
|
||
int top = y * w, bottom = (h - 1 - y) * w;
|
||
for (int x = 0; x < w; x++)
|
||
{
|
||
Color tmp = pixels[top + x];
|
||
pixels[top + x] = pixels[bottom + x];
|
||
pixels[bottom + x] = tmp;
|
||
}
|
||
}
|
||
tex.SetPixels(pixels);
|
||
tex.Apply();
|
||
}
|
||
|
||
private static Texture2D StitchOrSingle(List<Texture2D> textures)
|
||
{
|
||
if (textures.Count == 1) return textures[0];
|
||
|
||
int cols = Mathf.CeilToInt(Mathf.Sqrt(textures.Count));
|
||
int rows = Mathf.CeilToInt((float)textures.Count / cols);
|
||
int cellW = textures[0].width;
|
||
int cellH = textures[0].height;
|
||
|
||
Texture2D result = new Texture2D(cellW * cols, cellH * rows, TextureFormat.RGB24, false);
|
||
|
||
for (int i = 0; i < textures.Count; i++)
|
||
{
|
||
int col = i % cols;
|
||
int row = rows - 1 - (i / cols); // Unity Y=0 is bottom
|
||
if (textures[i] != null)
|
||
result.SetPixels(col * cellW, row * cellH, cellW, cellH, textures[i].GetPixels());
|
||
}
|
||
|
||
result.Apply();
|
||
|
||
foreach (var t in textures)
|
||
if (t != null) UnityEngine.Object.DestroyImmediate(t);
|
||
|
||
return result;
|
||
}
|
||
|
||
// Minimal 5×7 bitmap font for label rendering on captured textures.
|
||
// Each entry contains 7 row bitmasks; bit 4 = leftmost pixel, bit 0 = rightmost.
|
||
private static readonly Dictionary<char, int[]> s_BitmapFont = new Dictionary<char, int[]>
|
||
{
|
||
// Letters
|
||
['A'] = new[] { 14, 17, 17, 31, 17, 17, 0 },
|
||
['B'] = new[] { 30, 17, 17, 30, 17, 17, 30 },
|
||
['C'] = new[] { 14, 17, 16, 16, 16, 17, 14 },
|
||
['D'] = new[] { 30, 17, 17, 17, 17, 17, 30 },
|
||
['E'] = new[] { 31, 16, 16, 28, 16, 16, 31 },
|
||
['F'] = new[] { 31, 16, 30, 16, 16, 16, 0 },
|
||
['G'] = new[] { 15, 16, 16, 19, 17, 14, 0 },
|
||
['H'] = new[] { 17, 17, 31, 17, 17, 17, 0 },
|
||
['I'] = new[] { 31, 4, 4, 4, 4, 31, 0 },
|
||
['K'] = new[] { 17, 18, 20, 24, 20, 18, 17 },
|
||
['L'] = new[] { 16, 16, 16, 16, 16, 16, 31 },
|
||
['M'] = new[] { 17, 27, 21, 17, 17, 17, 0 },
|
||
['N'] = new[] { 17, 25, 21, 19, 17, 17, 0 },
|
||
['O'] = new[] { 14, 17, 17, 17, 17, 14, 0 },
|
||
['P'] = new[] { 30, 17, 17, 30, 16, 16, 0 },
|
||
['R'] = new[] { 30, 17, 17, 30, 20, 18, 0 },
|
||
['S'] = new[] { 14, 17, 16, 14, 1, 17, 14 },
|
||
['T'] = new[] { 31, 4, 4, 4, 4, 4, 0 },
|
||
['-'] = new[] { 0, 0, 0, 31, 0, 0, 0 },
|
||
// Digits (for frame labels: F0, F1, …)
|
||
['0'] = new[] { 14, 17, 19, 21, 25, 17, 14 },
|
||
['1'] = new[] { 4, 12, 4, 4, 4, 4, 14 },
|
||
['2'] = new[] { 14, 17, 1, 6, 8, 16, 31 },
|
||
['3'] = new[] { 14, 17, 1, 6, 1, 17, 14 },
|
||
['4'] = new[] { 2, 6, 10, 18, 31, 2, 2 },
|
||
['5'] = new[] { 31, 16, 30, 1, 1, 17, 14 },
|
||
['6'] = new[] { 6, 8, 16, 30, 17, 17, 14 },
|
||
['7'] = new[] { 31, 1, 2, 4, 8, 8, 8 },
|
||
['8'] = new[] { 14, 17, 17, 14, 17, 17, 14 },
|
||
['9'] = new[] { 14, 17, 17, 15, 1, 2, 12 },
|
||
};
|
||
|
||
private static void DrawLabelOnTexture(Texture2D tex, string label)
|
||
{
|
||
if (tex == null || string.IsNullOrEmpty(label)) return;
|
||
|
||
const int charW = 5, charH = 7, spacing = 1, pad = 2;
|
||
// Cap the label box to 1/5 of the image height (and keep it within the width),
|
||
// shrinking the pixel scale on small captures so the label can't dominate the frame.
|
||
int maxScaleByHeight = Mathf.Max(1, (tex.height / 5 - pad * 2) / charH);
|
||
int maxScaleByWidth = Mathf.Max(1, (tex.width - pad * 2) / (label.Length * (charW + spacing)));
|
||
int scale = Mathf.Clamp(7, 1, Mathf.Min(maxScaleByHeight, maxScaleByWidth));
|
||
|
||
int totalW = label.Length * (charW + spacing) * scale - spacing * scale + pad * 2;
|
||
int totalH = charH * scale + pad * 2;
|
||
|
||
Color[] pixels = tex.GetPixels();
|
||
int texW = tex.width, texH = tex.height;
|
||
int bgX = pad, bgY = texH - totalH - pad;
|
||
|
||
// Darken background box
|
||
for (int y = bgY; y < bgY + totalH; y++)
|
||
for (int x = bgX; x < bgX + totalW; x++)
|
||
{
|
||
if (x < 0 || x >= texW || y < 0 || y >= texH) continue;
|
||
Color c = pixels[y * texW + x];
|
||
pixels[y * texW + x] = new Color(c.r * 0.25f, c.g * 0.25f, c.b * 0.25f, 1f);
|
||
}
|
||
|
||
// Draw characters in white
|
||
for (int ci = 0; ci < label.Length; ci++)
|
||
{
|
||
if (!s_BitmapFont.TryGetValue(label[ci], out int[] rows)) continue;
|
||
int charOX = bgX + pad + ci * (charW + spacing) * scale;
|
||
int charOY = bgY + pad;
|
||
|
||
for (int row = 0; row < charH; row++)
|
||
{
|
||
int bits = rows[row];
|
||
int py = charOY + (charH - 1 - row) * scale;
|
||
for (int col = 0; col < charW; col++)
|
||
{
|
||
if (((bits >> (charW - 1 - col)) & 1) == 0) continue;
|
||
int px = charOX + col * scale;
|
||
for (int sy = 0; sy < scale; sy++)
|
||
for (int sx = 0; sx < scale; sx++)
|
||
{
|
||
int fx = px + sx, fy = py + sy;
|
||
if (fx >= 0 && fx < texW && fy >= 0 && fy < texH)
|
||
pixels[fy * texW + fx] = Color.white;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
tex.SetPixels(pixels);
|
||
tex.Apply();
|
||
}
|
||
|
||
// ==================== Path utilities ====================
|
||
|
||
private static string NormalizePath(string path)
|
||
{
|
||
if (string.IsNullOrEmpty(path)) return null;
|
||
|
||
path = path.Replace('\\', '/');
|
||
|
||
string dataPath = Application.dataPath.Replace('\\', '/');
|
||
string projectRoot = dataPath.Substring(0, dataPath.LastIndexOf('/'));
|
||
|
||
if (path.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
|
||
path = path.Substring(projectRoot.Length).TrimStart('/');
|
||
|
||
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) &&
|
||
!path.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase))
|
||
path = "Assets/" + path.TrimStart('/');
|
||
|
||
return path;
|
||
}
|
||
|
||
// ==================== UIToolkit helpers ====================
|
||
|
||
#if UNITY_2021_2_OR_NEWER
|
||
// Samples a small grid of pixels from the center of the RT to decide whether
|
||
// the panel has rendered anything yet. Returns true when every sampled pixel
|
||
// is opaque black (i.e. nothing has been drawn).
|
||
private static bool IsRenderTextureBlack(RenderTexture rt)
|
||
{
|
||
if (rt == null) return true;
|
||
RenderTexture prev = RenderTexture.active;
|
||
RenderTexture.active = rt;
|
||
|
||
// Read a 4×4 block from the center of the texture.
|
||
int sampleW = Mathf.Min(4, rt.width);
|
||
int sampleH = Mathf.Min(4, rt.height);
|
||
int startX = (rt.width - sampleW) / 2;
|
||
int startY = (rt.height - sampleH) / 2;
|
||
|
||
Texture2D probe = new Texture2D(sampleW, sampleH, TextureFormat.ARGB32, false);
|
||
probe.ReadPixels(new Rect(startX, startY, sampleW, sampleH), 0, 0);
|
||
probe.Apply();
|
||
RenderTexture.active = prev;
|
||
|
||
Color32[] pixels = probe.GetPixels32();
|
||
UnityEngine.Object.DestroyImmediate(probe);
|
||
|
||
foreach (Color32 c in pixels)
|
||
{
|
||
// If any pixel is non-black or has alpha > 0, the panel has rendered.
|
||
if (c.r > 2 || c.g > 2 || c.b > 2 || c.a > 2)
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Unity ships a default runtime theme under the com.unity.ui package. When the
|
||
// UI Builder's per-uxml theme map can't be located (Builder package not present,
|
||
// or this uxml hasn't been opened in Builder), we fall back to this so text and
|
||
// controls render with sensible defaults instead of an empty stylesheet.
|
||
private static ThemeStyleSheet GetDefaultRuntimeTheme()
|
||
{
|
||
string[] candidates =
|
||
{
|
||
"Packages/com.unity.ui/PackageResources/StyleSheets/Generated/UnityDefaultRuntimeTheme.tss",
|
||
"Packages/com.unity.toolkits.ui/PackageResources/StyleSheets/Generated/UnityDefaultRuntimeTheme.tss",
|
||
"UI Toolkit/UnityDefaultRuntimeTheme.tss",
|
||
};
|
||
foreach (string p in candidates)
|
||
{
|
||
var tss = AssetDatabase.LoadAssetAtPath<ThemeStyleSheet>(p);
|
||
if (tss != null) return tss;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static ThemeStyleSheet GetUIBuilderTheme(string uxmlPath)
|
||
{
|
||
Type builderDocType = null;
|
||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||
{
|
||
builderDocType = asm.GetType("Unity.UI.Builder.BuilderDocument");
|
||
if (builderDocType != null) break;
|
||
}
|
||
if (builderDocType == null) return null;
|
||
|
||
UnityEngine.Object[] instances = Resources.FindObjectsOfTypeAll(builderDocType);
|
||
if (instances.Length == 0) return null;
|
||
|
||
var instance = instances[0];
|
||
BindingFlags nonPublic = BindingFlags.Instance | BindingFlags.NonPublic;
|
||
BindingFlags pub = BindingFlags.Instance | BindingFlags.Public;
|
||
|
||
if (uxmlPath != null)
|
||
{
|
||
var listField = builderDocType.GetField("m_SavedBuilderUxmlToThemeStyleSheetList", nonPublic);
|
||
if (listField?.GetValue(instance) is IList list)
|
||
{
|
||
foreach (object entry in list)
|
||
{
|
||
Type entryType = entry.GetType();
|
||
if (entryType.GetField("UxmlURI", pub)?.GetValue(entry) is string uxmlUri
|
||
&& uxmlUri.Contains(uxmlPath)
|
||
&& entryType.GetField("ThemeStyleSheetURI", pub)?.GetValue(entry) is string themeUri
|
||
&& themeUri.StartsWith("project://database/"))
|
||
{
|
||
int qIdx = themeUri.IndexOf('?');
|
||
if (qIdx < 0) qIdx = themeUri.Length;
|
||
string assetPath = Uri.UnescapeDataString(
|
||
themeUri.Substring("project://database/".Length, qIdx - "project://database/".Length));
|
||
ThemeStyleSheet tss = AssetDatabase.LoadAssetAtPath<ThemeStyleSheet>(assetPath);
|
||
if (tss != null) return tss;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return builderDocType.GetField("m_CurrentCanvasThemeStyleSheetReference", nonPublic)
|
||
?.GetValue(instance) as ThemeStyleSheet;
|
||
}
|
||
|
||
// ----- Why this exists (bisected 2026-05-14, verified on this codebase) -----
|
||
// In Play Mode, a UIDocument's panel renders via UIElementsRuntimeUtility's
|
||
// player-loop hook. In Edit Mode that hook is dormant, so a PanelSettings with
|
||
// targetTexture set will lay out correctly but never flush draw commands —
|
||
// the RT stays at its clear color forever. Neither
|
||
// UIElementsRuntimeUtility.UpdateRuntimePanels() (runs layout only)
|
||
// UIElementsRuntimeUtility.RepaintOverlayPanels() (no-op in edit mode here)
|
||
// PanelSettings.GetOrCreatePanel() (panel already exists)
|
||
// gameView.Repaint() (works, but redundant)
|
||
// is required.
|
||
//
|
||
// Unity 2021/2022:
|
||
// BaseRuntimePanel.Repaint(Event) runs both the visual-tree pass AND the
|
||
// synchronous draw submission, so calling that one method is enough.
|
||
//
|
||
// Unity 6 (6000.x):
|
||
// The pipeline was split. Repaint(Event) now only marks UIR's render chain
|
||
// as needing work; the draw submission moved into a separate zero-arg
|
||
// Render()-family method that the player loop normally calls. In Edit Mode
|
||
// nothing calls it, AND a foregrounded editor frame is required for the
|
||
// render thread to actually flush UIR's mesh chunks. So on Unity 6 we must
|
||
// ALSO invoke DirectPanelRender(), which is the synchronous "do the draw
|
||
// right now on this thread" entry that bypasses focus / player-loop gates.
|
||
//
|
||
// The UpdateRuntimePanels call is kept as a cheap defensive step so Yoga's
|
||
// layout pass can settle if a stylesheet loads asynchronously.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
private static MethodInfo s_UpdateRuntimePanelsMethod;
|
||
private static bool s_UpdateRuntimePanelsSearched;
|
||
|
||
// Cached per panel type — Unity 6 added a zero-arg synchronous draw entry on
|
||
// BaseRuntimePanel; the exact name has churned across versions, so we resolve
|
||
// by signature (parameterless instance method) and cache the first hit.
|
||
private static MethodInfo s_DirectRenderMethod;
|
||
private static Type s_DirectRenderOwnerType;
|
||
|
||
private static void EnsureUpdateRuntimePanelsLoaded()
|
||
{
|
||
if (s_UpdateRuntimePanelsSearched) return;
|
||
s_UpdateRuntimePanelsSearched = true;
|
||
try
|
||
{
|
||
Type runtimeUtilType = typeof(VisualElement).Assembly
|
||
.GetType("UnityEngine.UIElements.UIElementsRuntimeUtility");
|
||
s_UpdateRuntimePanelsMethod = runtimeUtilType?.GetMethod(
|
||
"UpdateRuntimePanels",
|
||
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
|
||
}
|
||
catch { /* type not available in this Unity version */ }
|
||
}
|
||
|
||
// Walks up the panel's type hierarchy to find Repaint(Event) and invokes it
|
||
// with a null event. On 2021/2022 this also issues draws; on Unity 6 this
|
||
// only requests them — DirectPanelRender below performs the actual submission.
|
||
private static void DirectPanelRepaint(IPanel panel)
|
||
{
|
||
if (panel == null) return;
|
||
Type t = panel.GetType();
|
||
while (t != null)
|
||
{
|
||
MethodInfo repaint = t.GetMethod("Repaint",
|
||
BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance,
|
||
null, new[] { typeof(Event) }, null);
|
||
if (repaint != null)
|
||
{
|
||
try { repaint.Invoke(panel, new object[] { null }); return; }
|
||
catch { /* try base class */ }
|
||
}
|
||
t = t.BaseType;
|
||
}
|
||
}
|
||
|
||
// Synchronously flushes the panel's UIR command list to PanelSettings.targetTexture.
|
||
// No-op on Unity versions that don't have a parameterless render method (2021/2022
|
||
// — there DirectPanelRepaint already does the draw). On Unity 6 this is what
|
||
// removes the dependency on the editor being the foreground OS window: the call
|
||
// executes inline on the calling thread instead of waiting for a focus-throttled
|
||
// editor frame submission.
|
||
private static void DirectPanelRender(IPanel panel)
|
||
{
|
||
if (panel == null) return;
|
||
|
||
Type panelType = panel.GetType();
|
||
if (s_DirectRenderMethod == null || s_DirectRenderOwnerType != panelType)
|
||
{
|
||
s_DirectRenderOwnerType = panelType;
|
||
s_DirectRenderMethod = null;
|
||
|
||
// Names seen across UI Toolkit revisions. First parameterless match wins.
|
||
// We deliberately exclude "Repaint" — that one's handled separately and
|
||
// taking a parameterless overload would skip its Event-driven setup.
|
||
string[] candidates = { "Render", "RenderImmediately", "DoRender", "DoRepaint" };
|
||
|
||
Type t = panelType;
|
||
while (t != null && s_DirectRenderMethod == null)
|
||
{
|
||
foreach (string name in candidates)
|
||
{
|
||
MethodInfo m = t.GetMethod(name,
|
||
BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance,
|
||
null, Type.EmptyTypes, null);
|
||
if (m != null) { s_DirectRenderMethod = m; break; }
|
||
}
|
||
t = t.BaseType;
|
||
}
|
||
}
|
||
|
||
if (s_DirectRenderMethod != null)
|
||
{
|
||
try { s_DirectRenderMethod.Invoke(panel, null); } catch { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
private static void TryForceUIDocumentRepaint(UIDocument uiDoc)
|
||
{
|
||
if (uiDoc == null) return;
|
||
try
|
||
{
|
||
EnsureUpdateRuntimePanelsLoaded();
|
||
|
||
VisualElement root = uiDoc.rootVisualElement;
|
||
if (root != null) root.MarkDirtyRepaint();
|
||
|
||
// Layout pass (lets Yoga settle if anything is still resolving).
|
||
try { s_UpdateRuntimePanelsMethod?.Invoke(null, null); } catch { }
|
||
|
||
IPanel panel = root?.panel;
|
||
// 2021/2022: this also draws. Unity 6: this only requests a draw.
|
||
DirectPanelRepaint(panel);
|
||
// Unity 6 only: actually submit the draw on this thread. No-op elsewhere.
|
||
DirectPanelRender(panel);
|
||
}
|
||
catch { /* Ignore — reflection targets may not exist in all Unity versions */ }
|
||
}
|
||
#endif
|
||
|
||
// ==================== GIF capture (as_gif) ====================
|
||
|
||
private sealed class GifParams
|
||
{
|
||
public int FrameCount; // -1 when not supplied (caller treats as error)
|
||
public int Fps;
|
||
public int ColorCount;
|
||
public float StartDelay; // seconds
|
||
}
|
||
|
||
// Returns null when as_gif is absent / falsey. Sub-parameters are accepted either as
|
||
// siblings of as_gif or nested inside an as_gif object; camelCase and snake_case keys
|
||
// are both honored. frameCount is required — when missing it is left at -1 so the
|
||
// capture coroutine can surface a clear error.
|
||
private static GifParams ParseGifParams(JObject p)
|
||
{
|
||
JToken ag = p?["as_gif"];
|
||
if (ag == null || ag.Type == JTokenType.Null) return null;
|
||
if (ag.Type == JTokenType.Boolean && !ag.Value<bool>()) return null;
|
||
if (ag.Type == JTokenType.String)
|
||
{
|
||
string s = ag.ToString().Trim().ToLower();
|
||
if (s == "false" || s == "0" || s == "no" || s == "") return null;
|
||
}
|
||
|
||
JObject src = ag as JObject;
|
||
Func<string[], JToken> get = keys =>
|
||
{
|
||
foreach (string k in keys) { JToken t = src?[k]; if (t != null && t.Type != JTokenType.Null) return t; }
|
||
foreach (string k in keys) { JToken t = p[k]; if (t != null && t.Type != JTokenType.Null) return t; }
|
||
return null;
|
||
};
|
||
|
||
int? fc = AsInt(get(new[] { "frameCount", "frame_count" }));
|
||
int fps = Mathf.Clamp(AsInt(get(new[] { "fps" })) ?? 25, 10, 30);
|
||
int cc = Mathf.Clamp(AsInt(get(new[] { "colorCount", "color_count" })) ?? 128, 64, 256);
|
||
float delay = Mathf.Clamp(AsFloat(get(new[] { "startDelay", "start_delay" })) ?? 0f, 0f, 5f);
|
||
|
||
return new GifParams
|
||
{
|
||
FrameCount = fc.HasValue ? Mathf.Clamp(fc.Value, 1, 200) : -1,
|
||
Fps = fps,
|
||
ColorCount = cc,
|
||
StartDelay = delay,
|
||
};
|
||
}
|
||
|
||
// capture_game_view resolution scale factor (0.25–1.0, default 0.5).
|
||
private static float ParseScale(JObject p)
|
||
{
|
||
return Mathf.Clamp(AsFloat(p?["scale"]) ?? 1.0f, 0.25f, 1.0f);
|
||
}
|
||
|
||
private static bool ValidateStaticPlayModeCapture(
|
||
JObject p, Action<object> setResult)
|
||
{
|
||
if (!EditorApplication.isPlaying) return true;
|
||
|
||
bool allow = false;
|
||
JToken allowToken =
|
||
p?["allow_static_in_play_mode"] ?? p?["allowStaticInPlayMode"];
|
||
if (allowToken != null)
|
||
{
|
||
try { allow = allowToken.ToObject<bool>(); }
|
||
catch { allow = false; }
|
||
}
|
||
|
||
string reason = (
|
||
p?["static_reason"] ?? p?["staticReason"]
|
||
)?.ToString()?.Trim();
|
||
if (allow && !string.IsNullOrEmpty(reason))
|
||
return true;
|
||
|
||
setResult(Response.Error(
|
||
"Static camera capture is blocked in Play Mode because a single frame " +
|
||
"cannot verify animation, movement, particles, physics, transitions, or other " +
|
||
"dynamic behavior. Do not retry this static capture. Pass record_game_view on " +
|
||
"the same exec_runtime_script (or legacy execute_csharp_script) that triggers " +
|
||
"or restarts the effect. Only when " +
|
||
"the user explicitly needs genuinely static Play Mode output may you retry with " +
|
||
"allow_static_in_play_mode=true and a non-empty static_reason."));
|
||
return false;
|
||
}
|
||
|
||
private static int? AsInt(JToken t)
|
||
{
|
||
if (t == null) return null;
|
||
try { return t.ToObject<int>(); } catch { }
|
||
return int.TryParse(t.ToString(), out int v) ? v : (int?)null;
|
||
}
|
||
|
||
private static float? AsFloat(JToken t)
|
||
{
|
||
if (t == null) return null;
|
||
try { return t.ToObject<float>(); } catch { }
|
||
return float.TryParse(t.ToString(), System.Globalization.NumberStyles.Float,
|
||
System.Globalization.CultureInfo.InvariantCulture, out float v) ? v : (float?)null;
|
||
}
|
||
|
||
// Captures gif.FrameCount frames from the supplied producer, pacing capture to the
|
||
// target fps and recording each frame's ACTUAL measured interval as its GIF display
|
||
// duration (so playback tracks real recording speed, including editor hitches), after
|
||
// an optional startDelay. The producer must return a fresh Texture2D per call
|
||
// (ownership transfers here — frames are destroyed after encoding). beforeFrame, when
|
||
// supplied, runs to completion before each capture (used to repaint a Game View);
|
||
// its yields count toward the frame's measured display duration.
|
||
private static IEnumerator CaptureFramesToGif(
|
||
GifParams gif, Func<Texture2D> produceFrame,
|
||
string customPath, string customFile, string prefix,
|
||
Action<object> setResult, Func<IEnumerator> beforeFrame = null)
|
||
{
|
||
if (gif.FrameCount < 1)
|
||
{
|
||
setResult(Response.Error("as_gif requires 'frameCount' in the range 1-200."));
|
||
yield break;
|
||
}
|
||
|
||
// startDelay: wait the requested wall-clock seconds before capturing.
|
||
if (gif.StartDelay > 0f)
|
||
{
|
||
double until = EditorApplication.timeSinceStartup + gif.StartDelay;
|
||
while (EditorApplication.timeSinceStartup < until)
|
||
yield return null;
|
||
}
|
||
|
||
double targetInterval = 1.0 / Mathf.Max(1, gif.Fps);
|
||
int nominalDelayCs = Mathf.Max(1, Mathf.RoundToInt(100f / Mathf.Max(1, gif.Fps)));
|
||
|
||
List<Texture2D> frames = new List<Texture2D>(gif.FrameCount);
|
||
List<int> delaysCs = new List<int>(gif.FrameCount);
|
||
|
||
double prevTime = EditorApplication.timeSinceStartup;
|
||
for (int i = 0; i < gif.FrameCount; i++)
|
||
{
|
||
// yield one editor tick so the player loop advances before each capture.
|
||
// runInBackground is set to true by the play action, so the player loop
|
||
// runs at full speed even when the editor is not focused.
|
||
yield return null;
|
||
|
||
// Pace to the target fps.
|
||
double waitUntil = prevTime + targetInterval;
|
||
while (EditorApplication.timeSinceStartup < waitUntil)
|
||
yield return null;
|
||
|
||
if (beforeFrame != null)
|
||
{
|
||
IEnumerator prep = beforeFrame();
|
||
while (prep.MoveNext()) yield return prep.Current;
|
||
}
|
||
|
||
double now = EditorApplication.timeSinceStartup;
|
||
int gapCs = Mathf.Max(1, Mathf.RoundToInt((float)((now - prevTime) * 100.0)));
|
||
prevTime = now;
|
||
|
||
Texture2D tex = null;
|
||
try { tex = produceFrame(); }
|
||
catch (Exception e) { CodelyLogger.LogWarning($"[ManageScreenshot] GIF frame {i} capture failed: {e.Message}"); }
|
||
|
||
if (tex != null)
|
||
{
|
||
// The interval just elapsed is the display duration of the PREVIOUS frame.
|
||
if (frames.Count > 0) delaysCs.Add(gapCs);
|
||
frames.Add(tex);
|
||
}
|
||
}
|
||
if (frames.Count > 0) delaysCs.Add(nominalDelayCs); // last frame: nominal interval
|
||
|
||
if (frames.Count == 0)
|
||
{
|
||
setResult(Response.Error("Failed to capture any frames for GIF."));
|
||
yield break;
|
||
}
|
||
|
||
object result;
|
||
try { result = BuildGifResponse(frames, delaysCs, gif, customPath, customFile, prefix); }
|
||
catch (Exception e)
|
||
{
|
||
foreach (Texture2D f in frames) if (f != null) UnityEngine.Object.DestroyImmediate(f);
|
||
CodelyLogger.LogError($"[ManageScreenshot] GIF encode failed: {e}");
|
||
result = Response.Error($"Failed to encode GIF: {e.Message}");
|
||
}
|
||
setResult(result);
|
||
}
|
||
|
||
private static object BuildGifResponse(
|
||
List<Texture2D> frames, List<int> delaysCs, GifParams gif,
|
||
string customPath, string customFilename, string prefix)
|
||
{
|
||
// GIF frames must share dimensions; drop any mismatched frame (shouldn't happen
|
||
// for a single capture source, but a Game View resize mid-capture could cause it).
|
||
// The per-frame delay list is filtered in lockstep so timing stays aligned.
|
||
int w = frames[0].width, h = frames[0].height;
|
||
List<Texture2D> usable = new List<Texture2D>(frames.Count);
|
||
List<int> usableDelay = new List<int>(frames.Count);
|
||
for (int i = 0; i < frames.Count; i++)
|
||
{
|
||
Texture2D f = frames[i];
|
||
if (f == null) continue;
|
||
if (f.width == w && f.height == h)
|
||
{
|
||
usable.Add(f);
|
||
usableDelay.Add(i < delaysCs.Count ? delaysCs[i] : 4);
|
||
}
|
||
else { CodelyLogger.LogWarning("[ManageScreenshot] Dropping GIF frame with mismatched size."); UnityEngine.Object.DestroyImmediate(f); }
|
||
}
|
||
if (usable.Count == 0) { usable.Add(frames[0]); usableDelay.Add(4); }
|
||
|
||
byte[] bytes = GifEncoder.Encode(usable, gif.ColorCount, usableDelay);
|
||
|
||
string fn = !string.IsNullOrEmpty(customFilename)
|
||
? (customFilename.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ? customFilename : customFilename + ".gif")
|
||
: $"{prefix}_{Timestamp()}.gif";
|
||
|
||
string dir = !string.IsNullOrEmpty(customPath)
|
||
? customPath
|
||
: Path.Combine(Path.GetDirectoryName(Application.dataPath), "screenshots");
|
||
|
||
Directory.CreateDirectory(dir);
|
||
string savePath = Path.Combine(dir, fn);
|
||
File.WriteAllBytes(savePath, bytes);
|
||
|
||
int gw = usable[0].width, gh = usable[0].height, frameCount = usable.Count;
|
||
foreach (Texture2D f in usable) UnityEngine.Object.DestroyImmediate(f);
|
||
CodelyLogger.Log($"[ManageScreenshot] Saved GIF: {savePath} ({frameCount} frames @ {gif.Fps}fps)");
|
||
|
||
return Response.Success("GIF captured successfully.", new
|
||
{
|
||
path = savePath,
|
||
width = gw,
|
||
height = gh,
|
||
frames = frameCount,
|
||
fps = gif.Fps,
|
||
});
|
||
}
|
||
|
||
// Self-contained animated GIF89a encoder: a median-cut global palette (built from a
|
||
// sample of all frames) plus standard GIF LZW compression. No external dependencies.
|
||
private static class GifEncoder
|
||
{
|
||
// frameDelaysCs holds each frame's display duration in 1/100 s. It is matched to
|
||
// the actual measured capture interval so playback runs at real recording speed;
|
||
// a missing/short entry falls back to the last known delay.
|
||
public static byte[] Encode(List<Texture2D> frames, int colorCount, IList<int> frameDelaysCs)
|
||
{
|
||
colorCount = Mathf.Clamp(colorCount, 2, 256);
|
||
int width = frames[0].width;
|
||
int height = frames[0].height;
|
||
|
||
// Pull pixels once (Unity returns bottom-up rows); reused for palette + indexing.
|
||
Color32[][] pixels = new Color32[frames.Count][];
|
||
for (int i = 0; i < frames.Count; i++)
|
||
pixels[i] = frames[i].GetPixels32();
|
||
|
||
Color32[] palette = BuildPalette(pixels, colorCount);
|
||
byte[] cube = BuildCubeMap(palette);
|
||
|
||
int bits = 1;
|
||
while ((1 << bits) < palette.Length) bits++;
|
||
bits = Mathf.Clamp(bits, 1, 8);
|
||
int tableSize = 1 << bits; // padded global color table entries
|
||
int minCodeSize = Math.Max(2, bits);
|
||
|
||
using (MemoryStream ms = new MemoryStream())
|
||
{
|
||
WriteAscii(ms, "GIF89a");
|
||
|
||
// Logical Screen Descriptor
|
||
WriteUInt16(ms, (ushort)width);
|
||
WriteUInt16(ms, (ushort)height);
|
||
ms.WriteByte((byte)(0x80 | ((bits - 1) << 4) | (bits - 1))); // GCT flag + color res + GCT size
|
||
ms.WriteByte(0); // background color index
|
||
ms.WriteByte(0); // pixel aspect ratio
|
||
|
||
// Global Color Table (padded to tableSize)
|
||
for (int i = 0; i < tableSize; i++)
|
||
{
|
||
if (i < palette.Length) { ms.WriteByte(palette[i].r); ms.WriteByte(palette[i].g); ms.WriteByte(palette[i].b); }
|
||
else { ms.WriteByte(0); ms.WriteByte(0); ms.WriteByte(0); }
|
||
}
|
||
|
||
// Netscape 2.0 application extension — loop forever
|
||
ms.WriteByte(0x21); ms.WriteByte(0xFF); ms.WriteByte(0x0B);
|
||
WriteAscii(ms, "NETSCAPE2.0");
|
||
ms.WriteByte(0x03); ms.WriteByte(0x01);
|
||
WriteUInt16(ms, 0); // 0 = infinite loop
|
||
ms.WriteByte(0x00);
|
||
|
||
byte[] indices = new byte[width * height];
|
||
int lastDelay = 4;
|
||
for (int fi = 0; fi < pixels.Length; fi++)
|
||
{
|
||
Color32[] frame = pixels[fi];
|
||
|
||
int delayCs = (frameDelaysCs != null && fi < frameDelaysCs.Count) ? frameDelaysCs[fi] : lastDelay;
|
||
delayCs = Mathf.Clamp(delayCs, 1, 65535);
|
||
lastDelay = delayCs;
|
||
|
||
// Graphic Control Extension (per-frame delay)
|
||
ms.WriteByte(0x21); ms.WriteByte(0xF9); ms.WriteByte(0x04);
|
||
ms.WriteByte(0x00); // packed: no transparency, disposal 0
|
||
WriteUInt16(ms, (ushort)delayCs);
|
||
ms.WriteByte(0x00); // transparent color index (unused)
|
||
ms.WriteByte(0x00); // block terminator
|
||
|
||
// Image Descriptor
|
||
ms.WriteByte(0x2C);
|
||
WriteUInt16(ms, 0); WriteUInt16(ms, 0); // left, top
|
||
WriteUInt16(ms, (ushort)width); WriteUInt16(ms, (ushort)height);
|
||
ms.WriteByte(0x00); // no local color table, not interlaced
|
||
|
||
// Flip rows top-down so the GIF matches the orientation EncodeToPNG
|
||
// produces from the same bottom-up Unity texture.
|
||
int o = 0;
|
||
for (int y = height - 1; y >= 0; y--)
|
||
{
|
||
int row = y * width;
|
||
for (int x = 0; x < width; x++)
|
||
{
|
||
Color32 c = frame[row + x];
|
||
indices[o++] = cube[((c.r >> 3) << 10) | ((c.g >> 3) << 5) | (c.b >> 3)];
|
||
}
|
||
}
|
||
|
||
WriteImageData(ms, indices, minCodeSize);
|
||
}
|
||
|
||
ms.WriteByte(0x3B); // trailer
|
||
return ms.ToArray();
|
||
}
|
||
}
|
||
|
||
// ---- palette (median cut) ----
|
||
|
||
private static Color32[] BuildPalette(Color32[][] pixels, int colorCount)
|
||
{
|
||
long total = 0;
|
||
foreach (Color32[] f in pixels) total += f.Length;
|
||
|
||
const int targetSamples = 24000;
|
||
int step = (int)Math.Max(1, total / targetSamples);
|
||
|
||
List<Color32> samples = new List<Color32>(Math.Min((int)Math.Min(total, int.MaxValue), targetSamples + 16));
|
||
int counter = 0;
|
||
foreach (Color32[] frame in pixels)
|
||
for (int i = 0; i < frame.Length; i++)
|
||
if ((counter++ % step) == 0) samples.Add(frame[i]);
|
||
|
||
if (samples.Count == 0) samples.Add(new Color32(0, 0, 0, 255));
|
||
return MedianCut(samples, colorCount);
|
||
}
|
||
|
||
private static int ChannelVal(Color32 c, int ch) => ch == 0 ? c.r : (ch == 1 ? c.g : c.b);
|
||
|
||
private static Color32[] MedianCut(List<Color32> pixels, int maxColors)
|
||
{
|
||
List<List<Color32>> boxes = new List<List<Color32>> { pixels };
|
||
|
||
while (boxes.Count < maxColors)
|
||
{
|
||
int bestIdx = -1, bestRange = -1, bestChannel = 0;
|
||
for (int b = 0; b < boxes.Count; b++)
|
||
{
|
||
List<Color32> box = boxes[b];
|
||
if (box.Count < 2) continue;
|
||
|
||
int rMin = 255, rMax = 0, gMin = 255, gMax = 0, bMin = 255, bMax = 0;
|
||
foreach (Color32 c in box)
|
||
{
|
||
if (c.r < rMin) rMin = c.r; if (c.r > rMax) rMax = c.r;
|
||
if (c.g < gMin) gMin = c.g; if (c.g > gMax) gMax = c.g;
|
||
if (c.b < bMin) bMin = c.b; if (c.b > bMax) bMax = c.b;
|
||
}
|
||
int rR = rMax - rMin, gR = gMax - gMin, bR = bMax - bMin;
|
||
int range = Math.Max(rR, Math.Max(gR, bR));
|
||
if (range > bestRange)
|
||
{
|
||
bestRange = range;
|
||
bestIdx = b;
|
||
bestChannel = (rR >= gR && rR >= bR) ? 0 : (gR >= bR ? 1 : 2);
|
||
}
|
||
}
|
||
|
||
if (bestIdx < 0) break; // every box is a single color — can't split further
|
||
|
||
List<Color32> target = boxes[bestIdx];
|
||
int ch = bestChannel;
|
||
target.Sort((x, y) => ChannelVal(x, ch).CompareTo(ChannelVal(y, ch)));
|
||
int mid = target.Count / 2;
|
||
boxes[bestIdx] = target.GetRange(0, mid);
|
||
boxes.Add(target.GetRange(mid, target.Count - mid));
|
||
}
|
||
|
||
Color32[] palette = new Color32[boxes.Count];
|
||
for (int i = 0; i < boxes.Count; i++)
|
||
{
|
||
long r = 0, g = 0, b = 0;
|
||
foreach (Color32 c in boxes[i]) { r += c.r; g += c.g; b += c.b; }
|
||
int n = Math.Max(1, boxes[i].Count);
|
||
palette[i] = new Color32((byte)(r / n), (byte)(g / n), (byte)(b / n), 255);
|
||
}
|
||
return palette;
|
||
}
|
||
|
||
// Precomputes nearest-palette-index for a 32×32×32 RGB cube (5 bits/channel) so
|
||
// per-pixel mapping is a single array lookup instead of an O(palette) search.
|
||
private static byte[] BuildCubeMap(Color32[] palette)
|
||
{
|
||
byte[] cube = new byte[32768];
|
||
for (int r = 0; r < 32; r++)
|
||
for (int g = 0; g < 32; g++)
|
||
for (int b = 0; b < 32; b++)
|
||
{
|
||
int cr = (r << 3) | 4, cg = (g << 3) | 4, cb = (b << 3) | 4;
|
||
int best = 0; long bestD = long.MaxValue;
|
||
for (int i = 0; i < palette.Length; i++)
|
||
{
|
||
int dr = cr - palette[i].r, dg = cg - palette[i].g, db = cb - palette[i].b;
|
||
long d = (long)dr * dr + (long)dg * dg + (long)db * db;
|
||
if (d < bestD) { bestD = d; best = i; }
|
||
}
|
||
cube[(r << 10) | (g << 5) | b] = (byte)best;
|
||
}
|
||
return cube;
|
||
}
|
||
|
||
// ---- LZW image data ----
|
||
|
||
private static void WriteImageData(MemoryStream ms, byte[] indices, int minCodeSize)
|
||
{
|
||
ms.WriteByte((byte)minCodeSize);
|
||
|
||
const int maxBits = 12;
|
||
const int maxMaxCode = 1 << maxBits; // 4096
|
||
|
||
int clearCode = 1 << minCodeSize;
|
||
int endCode = clearCode + 1;
|
||
int codeSize = minCodeSize + 1;
|
||
int nextCode = endCode + 1;
|
||
int maxCode = (1 << codeSize) - 1;
|
||
bool clearFlag = false;
|
||
|
||
Dictionary<int, int> dict = new Dictionary<int, int>();
|
||
List<byte> outBytes = new List<byte>();
|
||
int bitBuffer = 0, bitCount = 0;
|
||
|
||
// Mirrors the canonical (Poskanzer/UNIX-compress) GIF LZW output routine: a
|
||
// code is written at the CURRENT code size, then the code size grows lazily —
|
||
// only once nextCode has already exceeded the current size's range. Bumping any
|
||
// earlier writes a code one bit too wide and desyncs every GIF decoder.
|
||
Action<int> emit = code =>
|
||
{
|
||
bitBuffer |= code << bitCount;
|
||
bitCount += codeSize;
|
||
while (bitCount >= 8)
|
||
{
|
||
outBytes.Add((byte)(bitBuffer & 0xFF));
|
||
bitBuffer >>= 8;
|
||
bitCount -= 8;
|
||
}
|
||
|
||
if (clearFlag)
|
||
{
|
||
codeSize = minCodeSize + 1;
|
||
maxCode = (1 << codeSize) - 1;
|
||
clearFlag = false;
|
||
}
|
||
else if (nextCode > maxCode && codeSize < maxBits)
|
||
{
|
||
codeSize++;
|
||
maxCode = (codeSize == maxBits) ? maxMaxCode : ((1 << codeSize) - 1);
|
||
}
|
||
};
|
||
|
||
emit(clearCode);
|
||
|
||
if (indices.Length > 0)
|
||
{
|
||
int current = indices[0];
|
||
for (int i = 1; i < indices.Length; i++)
|
||
{
|
||
int k = indices[i];
|
||
int key = (current << 8) | k;
|
||
if (dict.TryGetValue(key, out int code))
|
||
{
|
||
current = code;
|
||
}
|
||
else
|
||
{
|
||
emit(current);
|
||
current = k;
|
||
if (nextCode < maxMaxCode)
|
||
{
|
||
dict[key] = nextCode++;
|
||
}
|
||
else
|
||
{
|
||
// Table full: reset, then emit a clear code (written at the
|
||
// current size; emit() then drops the size back to the start).
|
||
dict.Clear();
|
||
nextCode = endCode + 1;
|
||
clearFlag = true;
|
||
emit(clearCode);
|
||
}
|
||
}
|
||
}
|
||
emit(current);
|
||
}
|
||
|
||
emit(endCode);
|
||
if (bitCount > 0) outBytes.Add((byte)(bitBuffer & 0xFF));
|
||
|
||
// Pack into sub-blocks of at most 255 data bytes, terminated by a zero block.
|
||
int offset = 0;
|
||
while (offset < outBytes.Count)
|
||
{
|
||
int chunk = Math.Min(255, outBytes.Count - offset);
|
||
ms.WriteByte((byte)chunk);
|
||
for (int i = 0; i < chunk; i++) ms.WriteByte(outBytes[offset + i]);
|
||
offset += chunk;
|
||
}
|
||
ms.WriteByte(0x00);
|
||
}
|
||
|
||
// ---- little-endian / ascii writers ----
|
||
|
||
private static void WriteUInt16(MemoryStream ms, ushort v)
|
||
{
|
||
ms.WriteByte((byte)(v & 0xFF));
|
||
ms.WriteByte((byte)((v >> 8) & 0xFF));
|
||
}
|
||
|
||
private static void WriteAscii(MemoryStream ms, string s)
|
||
{
|
||
foreach (char c in s) ms.WriteByte((byte)c);
|
||
}
|
||
}
|
||
|
||
// ==================== Public API (backward-compatible) ====================
|
||
|
||
public static void CaptureScreenshotMenuItem() => CaptureGameViewAndSave();
|
||
|
||
public static string CaptureGameViewAndSave(
|
||
string customPath = null, string customFilename = null,
|
||
int? width = null, int? height = null)
|
||
{
|
||
Texture2D tex = CaptureGameViewTexture();
|
||
if (tex == null) return null;
|
||
FlipTextureVertically(tex);
|
||
|
||
string fn = !string.IsNullOrEmpty(customFilename)
|
||
? (customFilename.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? customFilename : customFilename + ".png")
|
||
: $"GameView-{Timestamp()}.png";
|
||
|
||
string dir = !string.IsNullOrEmpty(customPath)
|
||
? customPath
|
||
: Path.Combine(Path.GetDirectoryName(Application.dataPath), "Screenshots");
|
||
|
||
Directory.CreateDirectory(dir);
|
||
string savePath = Path.Combine(dir, fn);
|
||
File.WriteAllBytes(savePath, tex.EncodeToPNG());
|
||
UnityEngine.Object.DestroyImmediate(tex);
|
||
CodelyLogger.Log($"[ManageScreenshot] Saved: {savePath}");
|
||
return savePath;
|
||
}
|
||
|
||
public static string CaptureAndSave(
|
||
string customPath = null, string customFilename = null,
|
||
int? width = null, int? height = null,
|
||
Camera specificCamera = null, string cameraPrefix = null)
|
||
{
|
||
Camera cam = specificCamera ?? Camera.main;
|
||
if (cam == null) { CodelyLogger.LogError("[ManageScreenshot] No camera found!"); return null; }
|
||
|
||
int w = width ?? 1920;
|
||
int h = height ?? 1080;
|
||
Texture2D tex = RenderCameraToTexture(cam, w, h);
|
||
if (tex == null) return null;
|
||
|
||
byte[] bytes = tex.EncodeToPNG();
|
||
UnityEngine.Object.DestroyImmediate(tex);
|
||
|
||
string prefix = cameraPrefix ?? (specificCamera != null ? specificCamera.name : "MainCamera");
|
||
string fn = !string.IsNullOrEmpty(customFilename)
|
||
? (customFilename.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? customFilename : customFilename + ".png")
|
||
: $"{prefix}-{Timestamp()}.png";
|
||
|
||
string dir = !string.IsNullOrEmpty(customPath)
|
||
? customPath
|
||
: Path.Combine(Path.GetDirectoryName(Application.dataPath), "Screenshots");
|
||
|
||
Directory.CreateDirectory(dir);
|
||
string savePath = Path.Combine(dir, fn);
|
||
File.WriteAllBytes(savePath, bytes);
|
||
CodelyLogger.Log($"[ManageScreenshot] Saved: {savePath}");
|
||
return savePath;
|
||
}
|
||
}
|
||
}
|