Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- The bridge can now be kept from starting, for build machines that have no use for it. Set `UNITY_CLI_BRIDGE_DISABLE=1` or pass `-noUnityCliBridge` on the Editor command line and the bridge builds nothing at all — no socket, no instance registration, no token file, no console warnings if any of that would have failed. A CI job that only opens the Editor to produce a player build gets a quiet log and no registry lock to contend over with parallel builds.

### Changed
- **`screenshot` now defaults to a JPEG capped at 1024px wide** (quality 75) instead of a full-resolution PNG. A capture is by far the most expensive thing an AI agent can ask for — a 1920×1080 PNG bills at roughly 2,040 image tokens, against about 576 for the same frame capped at 1024px — and the capped JPEG is still perfectly readable for checking UI state, which is what nearly every capture is for. Three things keep the change from surprising anyone: `--format png` still gives you lossless, a `--path` ending in `.png` selects PNG on its own, and an explicit `--width`/`--height` is left exactly as you asked. `--max-width 0` turns the cap off while keeping the automatic size. Tap and dump coordinates are unaffected — they scale from the last capture's size, so a downscaled screenshot's coordinates still land correctly.
- Unity Recorder is no longer installed alongside the package. Only the `record` commands and `qa run-sequence --record` ever used it, so projects that do not record no longer carry the dependency. If you do record, add `com.unity.recorder` to your project — `record start` now fails with that exact instruction when it is missing, and `record stop` / `record status` keep working on recordings you already have. `com.unity.test-framework`, which the test commands genuinely require, is now declared outright instead of arriving as a side effect of the Recorder dependency.

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Tests live in `tests/UnityCli.Cli.Tests/` (xUnit, `.NET`-testable surface only).
- **Scene paths:** Format `/Root[0]/Child[0]` with array notation for sibling indexing; `/` is the virtual scene root.
- **Scene/prefab node flags:** Convenience commands that point at a hierarchy node use `--node`; JSON patch specs still use `target`/`parent`.
- **Prefab editing:** Based on `SerializedProperty.propertyPath` (run `prefab inspect --with-values` to verify paths before patching).
- **Screenshot lightweight options:** `screenshot` defaults to PNG for compatibility; use `--format jpg|jpeg`, `--quality 1-100`, and `--max-width <int>` to reduce agent-facing image size without changing explicit `--width`/`--height` behavior.
- **Screenshot agent-facing defaults:** `screenshot` defaults to **JPEG quality 75 downscaled to 1024px wide** — a capture is the heaviest single response an agent can request (~2,040 image tokens at 1080p PNG vs ~576 capped), so the default is tuned for the common caller rather than for archival fidelity. `ScreenshotDefaults` (`Runtime/Protocol/`) owns the resolution so the CLI and the bridge agree and it stays unit-tested. Three rules keep the default from surprising anyone: an explicit `--format` always wins; with no `--format`, a `--path` ending in `.png` selects PNG (writing JPEG bytes into a file the caller named `.png` is worse than the tokens saved); and an explicit `--width`/`--height` suppresses the cap entirely — that gate predates the default and is why sized captures did not silently shrink. `--max-width 0` is the opt-out for the cap alone, and travels as the `MaxWidthUncapped` (-1) wire sentinel because `maxWidth == 0` already means "unspecified" (no `Nullable<T>` on the wire). Downscaling does not break QA coordinates: `qa tap`/`ui-dump`/`world-dump` scale from `LastCapturedWidth`/`Height`.
- **Record:** Play Mode 전용 Unity Recorder 기반 mp4. `record start`는 `STARTED+recordingId`를 즉시 반환하고, `--duration` 또는 600초 안전캡으로 자동 stop한다. `--wait`는 CLI가 `record status` sidecar를 폴링한다. force-rule 없음. 글로벌 busy에 참여하지 않고 자체 single-flight만 사용한다.
- **Console/test/list trims:** `read-console --no-stacktrace`, `test list --no-detail`, `test run/results --failures-only`, and `instances list --brief` are opt-in response trims only. Defaults preserve full output. Test failure trimming never changes summary counts or cached result files.
- **Test runner:** EditMode는 동기, PlayMode는 비동기(STARTED+runId 즉시 반환). `--wait`로 CLI 측 폴링. `--no-domain-reload`는 PlayMode 전용 속도 옵션이며 정합성 결정과 분리. 동시 실행 1회(`TEST_RUN_IN_PROGRESS` 거부). non-`Completed` 결과는 error envelope/exit code 1로 반환한다. force-rule 없음. 결과는 `Library/com.yhc509.unity-cli-bridge/test-runs/<runId>.json`.
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,9 @@ unity-cli play / pause / stop # Play Mode control
unity-cli compile # Trigger recompile
unity-cli compile --wait # Wait until compile/import finishes and bridge is reachable
unity-cli refresh --wait # Refresh assets and wait for Editor readiness
unity-cli screenshot --path /tmp/shot.png # Game View capture (default), or --view scene
unity-cli screenshot --format jpg --quality 70 --max-width 1024 --path /tmp/shot.jpg
unity-cli screenshot --path /tmp/shot.jpg # Game View capture (default), or --view scene
# Defaults: JPEG q75, downscaled to 1024px wide
unity-cli screenshot --format png --max-width 0 --path /tmp/shot.png # Lossless, full resolution
unity-cli record start --duration 5 --wait --path /tmp/play.mp4
unity-cli record start # Manual recording; stop with record stop
unity-cli record status
Expand Down Expand Up @@ -289,8 +290,8 @@ A typical AI repair loop is: make a focused code change, `unity-cli refresh`, ru

```bash
unity-cli qa click --qa-id StartButton
unity-cli screenshot --view game --path /tmp/qa-reference.png
unity-cli screenshot --view game --format jpg --quality 70 --max-width 1024 --path /tmp/qa-reference.jpg
unity-cli screenshot --view game --path /tmp/qa-reference.jpg
unity-cli screenshot --view game --format png --max-width 0 --path /tmp/qa-reference.png
unity-cli qa ui-dump --json
unity-cli qa ui-dump --text Start --interactable-only --limit 20 --omit-rect --json
unity-cli qa world-dump --json
Expand Down
19 changes: 18 additions & 1 deletion cli/UnityCli.Cli/Models/ParsedCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ private string BuildArgumentsJson()
height = ScreenshotHeight ?? 0,
format = ScreenshotFormat,
quality = ScreenshotQuality ?? 0,
maxWidth = ScreenshotMaxWidth ?? 0,
maxWidth = ResolveScreenshotMaxWidth(),
},
CommandKind.PackageList => new PackageListArgs
{
Expand Down Expand Up @@ -749,6 +749,23 @@ private string BuildRawArgumentsJson(JsonElement root)
: ScreenshotView;
}

/// <summary>
/// Maps <c>--max-width</c> onto the wire, where 0 means "unspecified, apply the default cap".
/// A caller who passed <c>--max-width 0</c> is asking for the opposite — no cap at all — so it
/// travels as the uncapped sentinel rather than as a value the bridge would read as silence.
/// </summary>
private int ResolveScreenshotMaxWidth()
{
if (ScreenshotMaxWidth is null)
{
return 0;
}

return ScreenshotMaxWidth.Value > 0
? ScreenshotMaxWidth.Value
: ScreenshotDefaults.MaxWidthUncapped;
}

private string? BuildAssetCreateOptionsJson()
{
var options = new Dictionary<string, object?>(AssetCustomOptions, StringComparer.OrdinalIgnoreCase);
Expand Down
4 changes: 3 additions & 1 deletion cli/UnityCli.Cli/Services/CliArgumentParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,9 @@ private static void ParseCommandOptions(ParsedCommand parsed, Queue<string> toke
parsed.ScreenshotQuality = RequireScreenshotQuality(RequireValue(tokens, "--quality"));
break;
case CommandKind.Screenshot when token == "--max-width":
parsed.ScreenshotMaxWidth = RequireInt(RequireValue(tokens, "--max-width"), "--max-width");
// 0 is allowed and means "no cap" — the way to opt out of the default downscale
// without having to state an explicit --width/--height.
parsed.ScreenshotMaxWidth = RequireInt(RequireValue(tokens, "--max-width"), "--max-width", minimumValue: 0);
break;
case CommandKind.ExecuteCode when token == "--code":
parsed.ExecuteCodeSnippet = RequireValue(tokens, "--code");
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Commands for editor state, compilation, play state, menus, arbitrary code execut
| `editor launch` | `editor launch [--gui] [--nographics] [--no-wait] [--timeout <sec>] [--editor-path <path>]` | local | `None` | Launches the Unity Editor for the selected project (headless -batchmode by default, GPU kept for rendering commands). Idempotent: reuses a live instance when one is already running. Waits for bridge readiness unless --no-wait. |
| `editor stop` | `editor stop [--force] [--no-wait] [--timeout <sec>]` | live | `OnDestructiveOp` | Gracefully quits the running editor for the selected project. Refuses with EDITOR_DIRTY when unsaved scene/prefab-stage changes exist; --force discards them. Waits for process exit unless --no-wait. |
| `execute-menu` | `execute-menu (--path "Menu/Item" \| --list "Prefix")` | live | `None` | Executes a Unity menu item or lists registered menu items matching a prefix in a running editor. |
| `screenshot` | `screenshot [--view game\|scene (default: game) \| --camera <name>] [--path <output.png\|output.jpg>] [--width N] [--height N] [--format png\|jpg\|jpeg] [--quality 1-100] [--max-width N]` | live | `None` | Captures a screenshot from the Game View, Scene View, or a named camera. Defaults to Game View; encoding defaults to PNG. Use --format jpg with --quality to reduce file size, and --max-width to downscale proportionally when --width/--height are not specified. The response includes image size, actual saved format, and screen-space metadata (`screenWidth`, `screenHeight`, `imageOrigin`, `coordinateOrigin`) for QA coordinate alignment. In Play Mode, --view game can downscale the native Game View capture but does not upscale it. |
| `screenshot` | `screenshot [--view game\|scene (default: game) \| --camera <name>] [--path <output.jpg\|output.png>] [--width N] [--height N] [--format png\|jpg\|jpeg] [--quality 1-100] [--max-width N\|0]` | live | `None` | Captures a screenshot from the Game View, Scene View, or a named camera. Defaults to Game View, JPEG at quality 75, and a 1024px width cap — agent-friendly defaults that cut image tokens by roughly 72% at 1080p. Override with --format png for lossless, --quality for the JPEG setting, --max-width N for a different cap, or --max-width 0 for no cap. The cap only applies when neither --width nor --height is given, and --path with a .png extension selects PNG when --format is omitted. The response includes image size, actual saved format, and screen-space metadata (`screenWidth`, `screenHeight`, `imageOrigin`, `coordinateOrigin`) for QA coordinate alignment; qa tap/ui-dump scale from the last captured size, so downscaled shots keep their coordinates usable. In Play Mode, --view game can downscale the native Game View capture but does not upscale it. |
| `record start` | `record start [--path <output.mp4>] [--fps <n> (default: 30)] [--max-width <n>] [--duration <seconds>] [--wait]` | live | `None` | Starts recording the Game View to an mp4 file via Unity Recorder. Returns immediately with a recordingId. Requires Play Mode. |
| `record stop` | `record stop` | live | `None` | Stops the active recording, finalizes the mp4, and returns the output path. |
| `record status` | `record status [--recording-id <id>]` | live | `None` | Reports whether a recording is active and the result of a finished recording. |
Expand Down
33 changes: 33 additions & 0 deletions tests/UnityCli.Cli.Tests/CliArgumentParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,39 @@ public void ToEnvelope_Screenshot_IncludesLightweightOptions()
Assert.Equal(1024, arguments.GetProperty("maxWidth").GetInt32());
}

[Fact]
public void ToEnvelope_Screenshot_WithoutMaxWidth_LeavesTheDefaultToTheBridge()
{
var parsed = CliArgumentParser.Parse(["screenshot"]);

using var document = JsonDocument.Parse(parsed.ToEnvelope().argumentsJson);

Assert.Equal(0, document.RootElement.GetProperty("maxWidth").GetInt32());
}

[Fact]
public void ToEnvelope_Screenshot_WithZeroMaxWidth_SendsTheUncappedSentinel()
{
// 0 on the wire already means "unspecified", so an explicit opt-out has to travel as
// something the bridge cannot read as silence.
var parsed = CliArgumentParser.Parse(["screenshot", "--max-width", "0"]);

using var document = JsonDocument.Parse(parsed.ToEnvelope().argumentsJson);

Assert.Equal(0, parsed.ScreenshotMaxWidth);
Assert.Equal(
ScreenshotDefaults.MaxWidthUncapped,
document.RootElement.GetProperty("maxWidth").GetInt32());
}

[Fact]
public void Parse_Screenshot_RejectsNegativeMaxWidth()
{
Assert.Throws<CliUsageException>(() => CliArgumentParser.Parse([
"screenshot", "--max-width", "-1"
]));
}

[Fact]
public void Parse_Screenshot_AcceptsCameraName()
{
Expand Down
4 changes: 2 additions & 2 deletions tests/UnityCli.Cli.Tests/ProtocolConstantsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ namespace UnityCli.Cli.Tests;
public sealed class ProtocolConstantsTests
{
[Fact]
public void ProtocolVersion_BumpedToSeven_ForHeadlessEditorCommands()
public void ProtocolVersion_BumpedToEight_ForAgentFacingScreenshotDefaults()
{
Assert.Equal("7", ProtocolConstants.ProtocolVersion);
Assert.Equal("8", ProtocolConstants.ProtocolVersion);
}

[Fact]
Expand Down
100 changes: 100 additions & 0 deletions tests/UnityCli.Cli.Tests/ScreenshotDefaultsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using UnityCli.Protocol;
using Xunit;

namespace UnityCli.Cli.Tests;

public class ScreenshotDefaultsTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void TryResolveFormat_WithNoRequestAndNoPath_DefaultsToJpg(string? requestedFormat)
{
Assert.True(ScreenshotDefaults.TryResolveFormat(requestedFormat!, null!, out string format));
Assert.Equal(ScreenshotDefaults.FormatJpg, format);
}

[Theory]
[InlineData("/tmp/shot.png")]
[InlineData("/tmp/shot.PNG")]
[InlineData(" /tmp/nested.dir/shot.Png ")]
public void TryResolveFormat_WithPngOutputPath_KeepsPng(string outputPath)
{
// Writing JPEG bytes into a file the caller named .png would be a worse outcome than the
// tokens the default saves, so an explicit extension outranks the default.
Assert.True(ScreenshotDefaults.TryResolveFormat(null!, outputPath, out string format));
Assert.Equal(ScreenshotDefaults.FormatPng, format);
}

[Theory]
[InlineData("/tmp/shot.jpg")]
[InlineData("/tmp/shot.jpeg")]
[InlineData("/tmp/shot.webp")]
[InlineData("/tmp/shot")]
[InlineData(".png")]
public void TryResolveFormat_WithNonPngOutputPath_UsesTheJpgDefault(string outputPath)
{
Assert.True(ScreenshotDefaults.TryResolveFormat(null!, outputPath, out string format));
Assert.Equal(ScreenshotDefaults.FormatJpg, format);
}

[Theory]
[InlineData("png", "png")]
[InlineData("PNG", "png")]
[InlineData(" jpg ", "jpg")]
[InlineData("jpeg", "jpg")]
[InlineData("JPEG", "jpg")]
public void TryResolveFormat_WithExplicitFormat_OverridesTheOutputExtension(string requested, string expected)
{
Assert.True(ScreenshotDefaults.TryResolveFormat(requested, "/tmp/shot.png", out string format));
Assert.Equal(expected, format);
}

[Theory]
[InlineData("bmp")]
[InlineData("gif")]
[InlineData("jpgg")]
public void TryResolveFormat_WithUnknownFormat_Fails(string requested)
{
Assert.False(ScreenshotDefaults.TryResolveFormat(requested, null!, out _));
}

[Fact]
public void ResolveMaxWidth_WithNothingSpecified_AppliesTheDefaultCap()
{
Assert.Equal(ScreenshotDefaults.DefaultMaxWidth, ScreenshotDefaults.ResolveMaxWidth(0, 0, 0));
}

[Fact]
public void ResolveMaxWidth_WithExplicitCap_UsesIt()
{
Assert.Equal(640, ScreenshotDefaults.ResolveMaxWidth(640, 0, 0));
}

[Fact]
public void ResolveMaxWidth_WithUncappedSentinel_DisablesTheCap()
{
Assert.Equal(0, ScreenshotDefaults.ResolveMaxWidth(ScreenshotDefaults.MaxWidthUncapped, 0, 0));
}

[Theory]
[InlineData(1920, 0)]
[InlineData(0, 1080)]
[InlineData(1920, 1080)]
public void ResolveMaxWidth_WithAnExplicitSize_LeavesTheSizeAlone(int width, int height)
{
// The explicit-size gate predates the default cap; keeping it is what stops this change
// from silently shrinking captures that already state the size they want.
Assert.Equal(0, ScreenshotDefaults.ResolveMaxWidth(0, width, height));
Assert.Equal(0, ScreenshotDefaults.ResolveMaxWidth(512, width, height));
}

[Theory]
[InlineData("jpg", ".jpg")]
[InlineData("png", ".png")]
public void FileExtension_MatchesTheResolvedFormat(string format, string expected)
{
Assert.Equal(expected, ScreenshotDefaults.FileExtension(format));
}
}
2 changes: 1 addition & 1 deletion tools/skills/unity-cli-operator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ for (int i = 0; i < workItems.Count; i++)
- scene path는 `/Root[0]/Child[0]` 형식으로 쓰고 `/`는 virtual scene root로 본다.
- root prefab 이름은 Unity 저장 규칙 때문에 파일 이름으로 정규화된다고 가정한다.
- `screenshot`은 `--view` 생략 시 game이 기본이다. Scene View가 필요하면 `--view scene`을 명시한다.
- **에이전트가 읽을 스크린샷은 `--format jpg --quality 75 --max-width 1024`를 기본으로 붙인다.** 기본 PNG full-resolution은 이미지 토큰을 크게 소비한다(1080p 기준 ~72% 절약). lossless가 필요할 때만 `--format png`.
- **스크린샷은 옵션 없이 그대로 찍으면 된다.** 기본값이 이미 JPEG quality 75 + 1024px 가로 축소라 에이전트가 읽기 좋은 크기로 나온다(1080p PNG 대비 이미지 토큰 ~72% 절약). lossless 원본이 필요할 때만 `--format png --max-width 0`을 붙인다. `--path`가 `.png`로 끝나면 `--format` 없이도 PNG로 저장된다.
- Play Mode 영상을 남겨야 하면 `record start --duration N --wait --path /tmp/out.mp4`를 쓴다. 수동 녹화는 `record start` 후 `record status`, `record stop` 순서로 종료한다. `record start`는 Play Mode 전용이고 `com.unity.recorder`가 설치된 프로젝트에서만 동작한다 — 미설치면 `RECORD_FAILED`와 함께 `unity-cli package add --name com.unity.recorder` 안내가 돌아오므로 그대로 설치한 뒤 재시도한다.
- `qa tap --x --y`에는 `screenshot`에서 확인한 이미지 좌표를 그대로 넣는다. 응답의 `imageOrigin`은 `top-left`, `coordinateOrigin`은 `bottom-left`다.
- `qa click`, `qa tap`, `qa swipe`는 기본 좌클릭/좌드래그이며, 우클릭 입력 경로를 검증할 때는 `--button right`를 붙인다.
Expand Down
10 changes: 5 additions & 5 deletions tools/skills/unity-cli-operator/references/command-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,14 +200,14 @@ ucli material info --project "$PROJECT" --path Assets/Materials/MyMat.mat --omit
## 스크린샷

```bash
# Game View 캡처 — 에이전트가 읽을 캡처는 jpg + max-width로 토큰 절약 (--view 생략 시 game이 기본)
ucli screenshot --project "$PROJECT" --path /tmp/capture.jpg --format jpg --quality 75 --max-width 1024 --output compact
# Game View 캡처 — 기본값이 이미 jpg q75 + 1024px 축소다 (--view 생략 시 game이 기본)
ucli screenshot --project "$PROJECT" --path /tmp/capture.jpg --output compact

# lossless가 필요할 때만 PNG
ucli screenshot --project "$PROJECT" --path /tmp/capture.png --output compact
# lossless 원본이 필요할 때만
ucli screenshot --project "$PROJECT" --path /tmp/capture.png --format png --max-width 0 --output compact

# Scene View 캡처
ucli screenshot --project "$PROJECT" --path /tmp/scene.png --view scene --output compact
ucli screenshot --project "$PROJECT" --path /tmp/scene.jpg --view scene --output compact
```

## 테스트 러너
Expand Down
Loading