Skip to content

Commit f315443

Browse files
Release v1.4.0: HDR/WCG MCP tool suite + Working Space binding model + ICtCp ToneLift
Major changes (full notes in CHANGELOG.md [1.4.0]): Added - HDR/gamut/tone-mapping MCP tool suite: list_display_profiles, set_display_profile, clear_simulated_profile, render_capture_node, preview_get/set/fit_view, image_stats, read_pixel_region, effect_get_hlsl. (10 new tools, 27 -> 37 total.) - Graph snapshot + view-control MCP tools: graph_snapshot, graph_get_view, graph_set_view, graph_fit_view. (4 new tools.) - 'Working Space' parameter node + display-profile mirroring. Mirrors the active display profile (live OS or simulated) into 14 typed analysis output fields (ActiveColorMode, HdrSupported, PeakNits, RedPrimary/GreenPrimary/BluePrimary/WhitePoint, etc.). Bind any downstream property to track Display Settings or simulated profiles automatically. - DisplayCapabilities extended with ACM / WCG state (activeColorMode, hdrSupported, hdrUserEnabled, wcgSupported, wcgUserEnabled) sourced from DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO_2. - MCP activity indicator on the toolbar (amber pulse dot). Changed - ICtCp Tone Map gained a ToneLift parameter (default 0.0 = identity, range [0..1]). Anchored polynomial mid-bump on top of I-channel Reinhard compression. Lets users opt in to a D2D-HdrToneMap-style dark-end lift while keeping pure peak compression as the default. Effect version 9 -> 10. - GraphEvaluator parameter-node branch now unpacks the full PropertyValue variant (bool, int, vector types) into AnalysisFieldValue::components, not just float. Removed - All per-effect 'follow the live monitor / working space' plumbing: 5 host-side per-frame writer blocks in MainWindow.xaml.cpp (~150 LOC), every '_hidden' cbuffer field on 12 ShaderLab effects, and the 'Current Monitor' / 'Working Space' enum modes on TargetGamut/SourceGamut/TargetRange + the SdrWhiteSource/ClipSource switches. Replaced by the new Working Space parameter node + first- class bindable Float2 primary parameters. Breaks saved graphs that used the old enum entries (accepted; no other users yet). Fixed - ICtCpToScRGB could emit NaN/Inf when an upstream-modified pqLms exceeded PQ valid range (PQ_EOTF blows up around V ~= 1.16). Added defensive saturate(pqLms) before the EOTF calls. Benefits all 8 ICtCp-using effects. - DispatchSync was UB on timeout (closed event then dereferenced state the in-flight lambda still held). Replaced with shared_ptr<State> capture + explicit WAIT_OBJECT_0 check + 30s timeout for compute/readback tools. - Snapshot capture no longer suppresses the live editor repaint (CaptureGraphAsPng now snapshots and restores the dirty flag). README decision log entries #51 (Working Space refactor) and #52 (ICtCp ToneLift + D2D-HDR-Tone-Map fixed-lift finding) added. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1d552ce commit f315443

22 files changed

Lines changed: 2743 additions & 726 deletions

.context/resume.md

Lines changed: 261 additions & 130 deletions
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,44 @@
33
All notable changes to ShaderLab will be documented in this file.
44
Format follows [Keep a Changelog](https://keepachangelog.com/).
55

6-
## [Unreleased]
6+
## [1.4.0] - 2026-05-05
7+
8+
### Removed
9+
- **Per-effect "follow the live monitor / working space" plumbing.** Removed all 5 host-side per-frame writer blocks from `MainWindow.xaml.cpp` (~150 LOC) and stripped every `_hidden` cbuffer field from 12 ShaderLab effects. The `Working Space` parameter node + property bindings (decision #51) are now the only path for tracking the active display profile from a graph.
10+
- **Effects converted** (12, all schema-broken — bumped `effectVersion`): Gamut Highlight, Luminance Highlight, CIE Chromaticity Plot, Gamut Source, Gamut Coverage, Gamut Map, ICtCp Gamut Map, ICtCp Boundary, ICtCp Tone Map (HDR → SDR), ICtCp Inverse Tone Map (SDR → HDR), ICtCp Highlight Desaturation, Luminance Statistics. Plus a light cleanup on the `Gamut Parameter` data node (dropped its `Working Space` value-enum entry).
11+
- **Replacement pattern**: each affected effect's gamut/range enum gains a `Custom` entry as the last index (existing static modes — `sRGB`, `DCI-P3`, `BT.2020`, etc. — are kept for strict-mode analysis without wiring 3 binds), and the host-managed primaries are promoted to first-class bindable `Float2` parameters (`RedPrimary`, `GreenPrimary`, `BluePrimary`, plus `WhitePoint` where applicable) gated by `visibleWhen "TargetGamut == 3"`. CIE Chromaticity Plot is the exception — its monitor primaries were always implicit, so the new `Float2` params are unconditional. Effects that consumed `(Ws)SdrWhiteNits_hidden` (ICtCp Tone Map / Inverse Tone Map / Highlight Desaturation / Luminance Statistics) drop the hidden field entirely; their existing numeric peak-nit parameter (`TargetPeakNits` / `SourcePeakNits` / `ClipNits`) becomes the single source.
12+
- **Old enum modes dropped**: every `Current Monitor` / `Working Space` entry from `TargetGamut`, `SourceGamut`, and `TargetRange`; entire `SdrWhiteSource` and `ClipSource` switch enums.
13+
- **Saved-graph breakage** (accepted, no other users yet): old graphs loaded from `.effectgraph` files keep stale `_hidden` properties in their `node.properties` map, but the shader cbuffer no longer references them and they're filtered out of the Properties panel + pin list (decision #35 retained for exactly this reason). Old `Working Space` enum-mode selections clamp to an invalid index → user re-picks. No graph format version bump.
14+
15+
### Added
16+
- **HDR / gamut / tone-mapping MCP tool suite (10 new, 27 → 37 total).** Round 2 of the agent-driven HDR workflow: simulate any display profile, capture any node's output, drive the preview view, run GPU stats, sample pixel grids, and inspect HLSL — all without mutating UI state.
17+
- `list_display_profiles` — GET `/display/profiles`. Returns 7 built-in presets (sRGB SDR, sRGB 270, P3 600, P3 1000, BT.2020 1000, BT.2020 4000, AdobeRGB) + the active live/simulated profile + `isSimulated` flag. Full caps in JSON: hdr/peak/sdrWhite/min/maxFullFrame nits, primaries (CIE xy), gamut id.
18+
- `set_display_profile` — POST `/display/profile`. Apply a simulated profile via exactly one of `{preset, presetIndex, iccPath, custom}`. `custom` accepts the full `{name, hdrEnabled, sdrWhiteNits, peakNits, minNits, maxFullFrameNits, primaryRed, primaryGreen, primaryBlue, whitePoint, gamut}` schema; missing fields fall back to sane defaults. Drives `MainWindow::ApplyDisplayProfile`, marks the graph dirty, forces the next render frame.
19+
- `clear_simulated_profile` — POST `/display/profile/clear`. Reverts to the live OS-reported profile.
20+
- `render_capture_node` — POST `/render/capture-node` body `{nodeId, inline?}`. Captures any node's resolved image as PNG without touching `m_previewNodeId`. Forces a render frame first so dirty nodes evaluate. With `inline=true` returns MCP-native image content (`type:"image"`, `mimeType:"image/png"`); without it, writes a unique `%TEMP%\shaderlab_node_<pid>_<seq>.png` and returns the path. Disambiguates not-found (404) vs not-ready (409 + `notReady:true, reason:"dirty"|"missingInputs"`).
21+
- `preview_get_view` / `preview_set_view` / `preview_fit_view` — symmetric with the graph view tools but for the preview pane. zoom is clamped to [0.01, 100.0] (matches the wheel-zoom range, much wider than the graph view's [0.1, 5.0]); pan is unclamped. `preview_set_view` returns post-clamp values.
22+
- `image_stats` — POST `/render/image-stats` body `{nodeId, channels?, nonzeroOnly?}`. Per-channel min/max/mean/median/p95/sum + nonzero counts, GPU-reduced on a fresh FP32 render of the target. Defaults to luminance + R + G + B + A; pass `channels:["luminance"]` to skip the others. Backed by a new `GraphEvaluator::ComputeStandaloneStats(dc, image, channels, nonzeroOnly)` so MCP doesn't have to own a separate reduction instance or mutate the graph for diagnostics.
23+
- `read_pixel_region` — POST `/render/pixel-region` body `{nodeId, x, y, w, h}`. Reads a small w×h region of FP32 RGBA pixels (scRGB linear-light) row-major as a flat float array. Capped at 32×32 (1024 pixels) and per-axis at 64; oversize requests fail 400 with the cap quoted so agents can chunk. Out-of-bounds rects clip to the image; empty after clip → 404.
24+
- `effect_get_hlsl` — GET `/effect/hlsl/{nodeId}`. Reads a custom-effect node's HLSL source, parameter list, compile state, last runtime error. For ShaderLab library effects also returns `isLibraryEffect:true` + `shaderLabEffectId`/`shaderLabEffectVersion` so the agent knows what's read-only. Non-custom nodes return `hasCustomEffect:false` (200, not 404) so the agent can probe any node.
25+
- **Graph snapshot + view-control MCP tools** (4 new). AI agents can now request a PNG of the live node-graph editor view and steer its pan/zoom exactly the way a user would. Implementation lives in `MainWindow::CaptureGraphAsPng()`, `MainWindow::FitGraphView()`, and `NodeGraphController::ContentBounds()`. The shared `MainWindow::RenderGraphScene()` helper drives both the live render tick and the off-screen snapshot so the two never drift.
26+
- `graph_snapshot` — POST `/graph/snapshot`. Always writes a unique `%TEMP%\shaderlab_graph_snapshot_<pid>_<seq>.png`. With `inline=true`, the JSON-RPC response carries MCP-native `content[].type=image` + `mimeType=image/png` so the agent gets the bytes directly. Snapshot renders the current pan/zoom at the live swap-chain panel size — same dimensions the user sees.
27+
- `graph_get_view` — GET `/graph/view`. Returns `{zoom, panX, panY, viewportW, viewportH, contentBounds, zoomLimits}`.
28+
- `graph_set_view` — POST `/graph/view` body `{zoom?, panX?, panY?}` (any subset). Applies via `NodeGraphController::SetZoom`/`SetPanOffset`, which mark the canvas dirty so the next 16ms render tick shows the new view. zoom is clamped to [0.1, 5.0]; pan has no clamp. Coord convention: `screen = zoom * canvas + pan`.
29+
- `graph_fit_view` — POST `/graph/view/fit` body `{padding?}` (viewport DIPs, default 40). No-op on empty graph.
30+
- **`NodeGraphController::ContentBounds()`** — returns the AABB of all node visuals in canvas (pre-pan/zoom) space. `{0,0,0,0}` when no nodes are laid out.
31+
- **MCP activity indicator on the toolbar.** A single dot next to the MCP toggle pulses amber when the server has handled a request in the last few seconds and turns off when idle. Provides at-a-glance feedback that an external agent is actually connected and active. The amber colour was chosen because the original green was nearly invisible against the toggle's blue Checked background.
32+
- **`Working Space` parameter node + display-profile mirroring.** New ShaderLab Parameter effect (no input pins, no output image pin) that mirrors the active display profile from the top-bar profile selector — live OS-reported caps or any simulated preset / ICC the user has applied — into 14 typed analysis output fields: `ActiveColorMode` (0=SDR, 1=WCG/ACM, 2=HDR), `HdrSupported`, `HdrUserEnabled`, `WcgSupported`, `WcgUserEnabled`, `IsSimulated`, `SdrWhiteNits`, `PeakNits`, `MinNits`, `MaxFullFrameNits`, plus four CIE-xy primaries `RedPrimary` / `GreenPrimary` / `BluePrimary` / `WhitePoint` (each Float2). Bind any downstream property to these fields via the property-binding system — e.g. wire a tone-mapper's peak-nits to `working_space.PeakNits` and it tracks Display Settings or simulated profile changes automatically. Replaces ad-hoc per-effect "follow the working space" toggles. Updated in `MainWindow::UpdateWorkingSpaceNodes()` which runs on `ApplyDisplayProfile`, `RevertToLiveDisplay`, the display-change callback, and once per render tick (cheap node-list walk; only marks dirty when at least one field actually changed, so freshly-added nodes pick up live values immediately without hooking every AddNode site).
33+
- **`DisplayCapabilities` extended with ACM / WCG state** (`activeColorMode`, `hdrSupported`, `hdrUserEnabled`, `wcgSupported`, `wcgUserEnabled`). Sourced from a new `DisplayMonitor::QueryAdvancedColorInfo2()` helper that calls `DisplayConfigGetDeviceInfo` with `DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO_2` (type 15, requires SDK 10.0.26100). Falls back to deriving `activeColorMode` from `caps.hdrEnabled` when the type-15 query is unavailable. Also trusts DisplayConfig's `bitsPerColorChannel` over the legacy DXGI heuristic, which reports 8 in many WCG configurations even when the actual scanout is 10-bit. All seven preset factories now stamp coherent ACM/WCG flags via the new `StampSimulatedColorMode()` helper so simulated profiles report a self-consistent display mode through the Working Space node.
34+
35+
### Changed
36+
- **The "Regular parameter nodes" branch in `GraphEvaluator::Evaluate` now unpacks the full `PropertyValue` variant** (`bool` → 0/1 float, `int32_t` / `uint32_t` → float, `float2` / `float3` / `float4` → component[0..N]) into `AnalysisFieldValue::components`. Previously only `float` was unpacked, so any vector or boolean property on a parameter node was silently zero. Used by the Working Space node for primary chromaticities (Float2) and capability flags (Float-as-bool); also benefits any future host-managed parameter nodes that need richer types.
37+
- **`Working Space Integration` README section rewritten** to describe the new "bind, don't hide" pattern for tracking the active display profile. Decision log gains entry #51; entry #35 (the `_hidden` suffix convention) cross-references #51 to record that the suffix filter is now legacy-compatibility only.
38+
- **`ICtCp Tone Map (HDR → SDR)` gained a `ToneLift` parameter** (default `0.0` = identity, range `[0..1]`). Adds an anchored polynomial mid-bump on top of the existing I-channel Reinhard compression: `f(x) = x + a·x·(1−x)` evaluated in normalized `[0, peakOut]` I-space then scaled back. Designed to let the operator approximate D2D `HdrToneMap`'s "make HDR readable on SDR" dark-end lift without giving up the hue-preservation property of pure I-channel compression. Quantitatively benchmarked against D2D HDR Tone Map on the `Colors of Journey` HDR test clip: at `ToneLift ≈ 0.6`, dark-region nit counts match D2D within ~10%; at `ToneLift = 0` the effect is its prior pure-compression behavior. Effect bumped from version 9 → 10 (drops `ToneLift` initialized to default on existing graphs). Note: the docstring no longer claims "saturation is preserved by construction" — only hue is. Lifting I without rescaling Ct/Cp can desaturate slightly, especially at higher `ToneLift`.
39+
40+
### Fixed
41+
- **`ICtCpToScRGB` could emit NaN/Inf when `pqLms` exceeded the PQ valid range `[0, 1]`.** The function transformed ICtCp → PQ-encoded LMS via the inverse matrix and then called `PQ_EOTF` on each component without clamping. For modified ICtCp inputs (e.g. tone-mapped I) or out-of-gamut chroma, the matrix product could push an LMS component above 1 (where `PQ_EOTF`'s denominator goes through zero and then negative around `V ≈ 1.16`) or below 0 (where `pow` of a negative is undefined). Added `pqLms = saturate(pqLms);` before the EOTF calls. In-domain values are unaffected; out-of-domain values now saturate to the nearest representable nit value rather than producing NaN that propagates into the inverse XYZ→scRGB matrix. Benefits all 8 ICtCp-using effects.
42+
- **`DispatchSync` was UB on timeout.** The original wait-and-deref pattern closed the event after a 10-second timeout, then dereferenced `*result` without checking `WAIT_OBJECT_0` — and the in-flight lambda still held references to stack `result`/`ex`/`event` which dangled if the caller had returned. Replaced with a `shared_ptr<State>` capture so the lambda can safely complete after the caller times out, an explicit `WAIT_OBJECT_0` check that throws on timeout (caller's `catch` returns 500 cleanly), and a 30s timeout for the heavier compute/readback tools. Closed by the new round-2 routes that need it; old routes benefit too.
43+
- **Snapshot capture no longer suppresses the live editor repaint.** `NodeGraphController::Render()` clears `m_needsRedraw` at the end. `CaptureGraphAsPng()` now snapshots the dirty flag before its render and re-sets it after, so an MCP-driven snapshot taken between frames doesn't cancel the next live render.
744

845
## [1.3.9] - 2026-05-05
946

Controls/NodeGraphController.cpp

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,35 @@ namespace ShaderLab::Controls
186186
m_needsRedraw = true;
187187
}
188188

189+
D2D1_RECT_F NodeGraphController::ContentBounds() const
190+
{
191+
if (m_visuals.empty())
192+
return D2D1::RectF(0.0f, 0.0f, 0.0f, 0.0f);
193+
194+
bool first = true;
195+
D2D1_RECT_F acc{};
196+
for (const auto& [id, v] : m_visuals)
197+
{
198+
const auto& b = v.bounds;
199+
if (b.right <= b.left || b.bottom <= b.top) continue;
200+
if (first)
201+
{
202+
acc = b;
203+
first = false;
204+
}
205+
else
206+
{
207+
acc.left = (std::min)(acc.left, b.left);
208+
acc.top = (std::min)(acc.top, b.top);
209+
acc.right = (std::max)(acc.right, b.right);
210+
acc.bottom = (std::max)(acc.bottom, b.bottom);
211+
}
212+
}
213+
if (first)
214+
return D2D1::RectF(0.0f, 0.0f, 0.0f, 0.0f);
215+
return acc;
216+
}
217+
189218
// -----------------------------------------------------------------------
190219
// Hit testing
191220
// -----------------------------------------------------------------------
@@ -622,6 +651,20 @@ namespace ShaderLab::Controls
622651
// Skip hidden properties (internal cbuffer plumbing).
623652
if (key.size() > 7 && key.ends_with(L"_hidden"))
624653
continue;
654+
// For nodes with a customEffect, only show input pins for properties
655+
// that correspond to a declared parameter. Properties that exist
656+
// solely as host-driven bootstrap values (e.g. Working Space, fed
657+
// by hiddenDefaults + UpdateWorkingSpaceNodes) must NOT appear as
658+
// input pins — they are sink-only analysis backing storage.
659+
if (node.customEffect.has_value())
660+
{
661+
bool isDeclaredParam = false;
662+
for (const auto& p : node.customEffect->parameters)
663+
{
664+
if (p.name == key) { isDeclaredParam = true; break; }
665+
}
666+
if (!isDeclaredParam) continue;
667+
}
625668
// Skip conditionally hidden parameters.
626669
if (node.customEffect.has_value())
627670
{

Controls/NodeGraphController.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ namespace ShaderLab::Controls
9595
// Used to place auto-positioned new nodes in the user's current view.
9696
void SetViewportSize(float w, float h) { m_viewportW = w; m_viewportH = h; }
9797

98+
// Returns the axis-aligned bounding box of all node visuals in canvas
99+
// (pre-pan/zoom) space. Returns {0,0,0,0} when no nodes are laid out.
100+
// Caller is responsible for calling RebuildLayout() first if the graph
101+
// may have changed since the last layout.
102+
D2D1_RECT_F ContentBounds() const;
103+
98104
// ---- Interaction ----
99105

100106
// Hit-test a canvas point. Returns the node ID under the point, or 0.

0 commit comments

Comments
 (0)