feat: camera-restore + LauncherUI UX 개선 + player fallback 강화 (#15)

camera-restore:
- IEngineStateProvider.SetCamera 반사 쓰기 (HmegDirectStateProvider)
- POST /camera/restore (BridgeHttpServer, StateRouter)
- Recorder --sidecar-url + camera_snapshot 캡처
- UiaPlayerHost.TryRestoreCamera, PlayerEngine 재생 전 복원
- 149 tests

LauncherUI (#15):
- Sidecar URL 체크박스 + 입력란 (녹화/재생 모두 연동)
- 재생 속도 슬라이더 (0.25x~4.0x, 기본 1.0x)
- 빌드 타임스탬프 타이틀바 표시
- 녹화 완료 후 RecordNameBox 초기화
- UiAnalysisWindow 추가

PlayerEngine (#15):
- CancellationToken 지원 (중단 버튼 동작)
- Focus 스텝 early return (no-op, issue #11)
- Type/Drag unresolvable UIA path fallback
- SpeedMultiplier 옵션

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-13 18:37:13 +09:00
parent 6bc71afd32
commit 11eb92b2b2
32 changed files with 1658 additions and 46 deletions

View File

@@ -41,8 +41,20 @@ public sealed class BridgeHttpServer : IDisposable
catch { return; }
try
{
var path = ctx.Request.Url?.AbsolutePath ?? "/";
var (status, body) = _router.Route(path);
var path = ctx.Request.Url?.AbsolutePath ?? "/";
var method = ctx.Request.HttpMethod ?? "GET";
string requestBody = string.Empty;
if (ctx.Request.HasEntityBody)
{
try
{
using var reader = new System.IO.StreamReader(
ctx.Request.InputStream, Encoding.UTF8);
requestBody = reader.ReadToEnd();
}
catch { /* ignore body read failures */ }
}
var (status, body) = _router.Route(method, path, requestBody);
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Response.StatusCode = (int)status;
ctx.Response.ContentType = "application/json";

View File

@@ -48,6 +48,17 @@ public sealed class ChainedEngineStateProvider : IEngineStateProvider
public bool GetRenderComplete() => _primary.GetRenderComplete();
/// <summary>
/// Camera writes go to the primary only (HmegDirectStateProvider).
/// The reflection fallback does not support writes; chaining writes would
/// risk applying a stale camera twice.
/// </summary>
public void SetCamera(CameraSnapshot snapshot)
{
try { _primary.SetCamera(snapshot); }
catch { /* never throw from sidecar thread */ }
}
private static bool IsDefault(CameraSnapshot c) =>
c.Eye is { Length: >= 3 } e && e[0] == 0 && e[1] == 0 && e[2] == 0 &&
c.Target is { Length: >= 3 } t && t[0] == 0 && t[1] == 0 && t[2] == 0;

View File

@@ -106,7 +106,18 @@ public sealed class HmEgBridgePlugin : EditorPlugin, IDisposable
catch { return null; }
};
var direct = new HmegDirectStateProvider(spaceProvider, viewportProvider, documentPathProvider);
// Dispatch camera writes onto the WPF UI thread so DependencyProperty
// setters on CameraCore are called from the correct thread.
Action<Action>? uiDispatch = null;
try
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is not null)
uiDispatch = action => dispatcher.Invoke(action);
}
catch { /* best-effort: leave null, SetCamera falls back to direct call */ }
var direct = new HmegDirectStateProvider(spaceProvider, viewportProvider, documentPathProvider, uiDispatch);
var fallback = new ReflectionEngineStateProvider(this);
return new ChainedEngineStateProvider(direct, fallback);
}

View File

@@ -80,4 +80,10 @@ public sealed class ReflectionEngineStateProvider : IEngineStateProvider
// expose a stable "frame finished" flag we can poll without an event.
return true;
}
public void SetCamera(CameraSnapshot snapshot)
{
// Reflection fallback does not implement camera write; silently no-op.
// The primary HmegDirectStateProvider handles this in production.
}
}

View File

@@ -1,6 +1,7 @@
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json;
using Recordingtest.Bridge;
namespace Recordingtest.Sut.EgBim.PluginHost;
@@ -20,12 +21,19 @@ public sealed class StateRouter
_port = port;
}
public (HttpStatusCode Status, string Body) Route(string path)
/// <summary>Backwards-compatible GET overload for unit tests.</summary>
public (HttpStatusCode Status, string Body) Route(string path) => Route("GET", path, "");
public (HttpStatusCode Status, string Body) Route(string method, string path, string body = "")
{
var p = (path ?? "/").TrimEnd('/');
if (p.Length == 0) p = "/";
var m = (method ?? "GET").ToUpperInvariant();
try
{
if (m == "POST" && p == "/camera/restore")
return (HttpStatusCode.OK, RestoreCamera(body));
return p switch
{
"/health" => (HttpStatusCode.OK, $"{{\"status\":\"ok\",\"port\":{_port}}}"),
@@ -42,6 +50,34 @@ public sealed class StateRouter
}
}
private string RestoreCamera(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
var r = doc.RootElement;
var eye = ReadVecFromJson(r, "eye");
var target = ReadVecFromJson(r, "target");
var up = ReadVecFromJson(r, "up");
double fov = r.TryGetProperty("fov", out var fovEl) ? fovEl.GetDouble() : 45.0;
_provider.SetCamera(new CameraSnapshot(eye, target, up, fov));
return "{\"ok\":true}";
}
catch (Exception ex)
{
return $"{{\"ok\":false,\"error\":{JsonString(ex.Message)}}}";
}
}
private static double[] ReadVecFromJson(JsonElement root, string key)
{
if (!root.TryGetProperty(key, out var arr)) return new double[3];
var result = new double[arr.GetArrayLength()];
int i = 0;
foreach (var e in arr.EnumerateArray()) result[i++] = e.GetDouble();
return result;
}
private string BuildSelection()
{
var ids = _provider.GetSelectedIds();