pi-exr-reader 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/exr_reader/exr_probe.py +457 -0
- package/exr_reader/index.ts +284 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luke Richardson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# pi-exr-reader
|
|
2
|
+
|
|
3
|
+
Pi extension that lets any pi session read `.exr` files — something pi's
|
|
4
|
+
`read` tool can't do.
|
|
5
|
+
|
|
6
|
+
The `exr_read` tool reports:
|
|
7
|
+
|
|
8
|
+
- **Part metadata** — size, data window, storage, line order, compression,
|
|
9
|
+
for every part of a multilayer EXR
|
|
10
|
+
- **Per-channel statistics** — min / max / mean / nonzero fraction, per
|
|
11
|
+
component (`UV_Box.R`, `st.G`, ...)
|
|
12
|
+
- **Exact float pixel values** at a point, full precision
|
|
13
|
+
- **Rect statistics** over an inclusive region
|
|
14
|
+
- **PNG preview** — 8-bit linear-mapped (or viewer-look tone-mapped) image
|
|
15
|
+
attached to the tool result when the active model accepts image input
|
|
16
|
+
|
|
17
|
+
Built for Arnold multi-AOV renders and UV/ST map inspection, but works on
|
|
18
|
+
any EXR: scanline or tiled, any compression, multilayer or single-layer.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
pi install npm:pi-exr-reader
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
or from a local clone:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
pi install /path/to/this/folder
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
The extension shells out to a Python probe
|
|
35
|
+
(`exr_reader/exr_probe.py`) using OpenEXR 3.x, numpy, and Pillow:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
pip install OpenEXR numpy Pillow
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The extension probes `python` / `python3` and fails with an install hint
|
|
42
|
+
if the packages are missing.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
Ask your model to inspect an EXR, e.g. *"what are the UV values at pixel
|
|
47
|
+
(540, 540) in render.exr"*. Coordinates are `(x, y)` with origin at the
|
|
48
|
+
top-left of the data window, y increasing down — matching the preview
|
|
49
|
+
PNG orientation.
|
|
50
|
+
|
|
51
|
+
Channel names are case-insensitive; a group name selects all its
|
|
52
|
+
components (`"uv_box"` → `UV_Box.R/.G/.B`).
|
|
53
|
+
|
|
54
|
+
## Development
|
|
55
|
+
|
|
56
|
+
Source lives in `exr_reader/` (`index.ts` + `exr_probe.py`). No build
|
|
57
|
+
step — pi loads the TypeScript directly. The production global install on
|
|
58
|
+
the author's machine is a copy at `~/.pi/agent/extensions/exr_reader/`;
|
|
59
|
+
re-copy after changes (or `pi -e <this folder>` to test from source).
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""exr_probe.py — EXR file reader for the pi `exr_read` tool.
|
|
3
|
+
|
|
4
|
+
Reads an EXR file (any compression, scanline or tiled, multi-part,
|
|
5
|
+
half/float/int channels) via the OpenEXR 3.x Python API and prints a
|
|
6
|
+
single JSON object to stdout. Exit code 0 on success (including
|
|
7
|
+
"ok": false results), non-zero on hard failure.
|
|
8
|
+
|
|
9
|
+
Coordinate convention (matches the preview PNG orientation):
|
|
10
|
+
(x, y) = pixel coordinates with origin at the TOP-LEFT of the part's
|
|
11
|
+
data window, x increasing to the right, y increasing DOWN.
|
|
12
|
+
Array index is [y, x]. Preview PNG top row = array row 0 = y=0.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python exr_probe.py FILE.exr [--pixel X Y] [--rect X0 Y0 X1 Y1]
|
|
16
|
+
[--channel NAME] [--preview-out PATH]
|
|
17
|
+
[--max-preview N] [--tone-map MODE] [--no-preview]
|
|
18
|
+
|
|
19
|
+
--tone-map MODE selects the preview mapping per component: "auto" (default),
|
|
20
|
+
"linear", or "tonemap".
|
|
21
|
+
linear = full [min,max] -> full range (any data; UV/ST maps, P/Z AOVs)
|
|
22
|
+
tonemap = viewer look: 1.0 = white, sRGB gamma, clip above (what Nuke/
|
|
23
|
+
Photoshop/AE show for an EXR)
|
|
24
|
+
auto = linear when the data fits ~[0, 1.05] (maps/LDR), tonemap for
|
|
25
|
+
image-like HDR (min ~>= 0, max > 1.05), linear full-range for
|
|
26
|
+
data with a large negative span (P/Z-like). The per-component
|
|
27
|
+
mapping used is reported in the preview JSON.
|
|
28
|
+
|
|
29
|
+
--channel NAME selects which channels get pixel values / rect stats /
|
|
30
|
+
the preview. Matches a channel name case-insensitively (e.g. "R", "U",
|
|
31
|
+
"st"); a GROUP name also selects all its components ("uv" matches
|
|
32
|
+
"uv.R", "uv.G", "uv.B"), and an exact component name ("uv.r") still
|
|
33
|
+
works. May be given multiple times. A filter that matches nothing is
|
|
34
|
+
an error that lists the available channel names. Omitted: stats cover
|
|
35
|
+
every channel; pixel queries report every channel; preview composes
|
|
36
|
+
the first R,G,B channels (or the first up-to-3 channels if no R/G/B
|
|
37
|
+
names exist).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import json
|
|
42
|
+
import math
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import sys
|
|
46
|
+
import tempfile
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
import numpy as np
|
|
50
|
+
import OpenEXR
|
|
51
|
+
except ImportError as _e: # surfaced via fail() in main()
|
|
52
|
+
np = None # type: ignore[assignment]
|
|
53
|
+
OpenEXR = None # type: ignore[assignment]
|
|
54
|
+
_IMPORT_ERROR = _e
|
|
55
|
+
else:
|
|
56
|
+
_IMPORT_ERROR = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fail(msg, **extra):
|
|
60
|
+
out = {"ok": False, "error": msg}
|
|
61
|
+
out.update(extra)
|
|
62
|
+
print(json.dumps(sanitize(out)))
|
|
63
|
+
sys.exit(0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sanitize(o):
|
|
67
|
+
"""Replace non-finite floats with strings so the output is strict JSON."""
|
|
68
|
+
if isinstance(o, float):
|
|
69
|
+
if math.isnan(o):
|
|
70
|
+
return "NaN"
|
|
71
|
+
if math.isinf(o):
|
|
72
|
+
return "inf" if o > 0 else "-inf"
|
|
73
|
+
return o
|
|
74
|
+
if isinstance(o, dict):
|
|
75
|
+
return {k: sanitize(v) for k, v in o.items()}
|
|
76
|
+
if isinstance(o, (list, tuple)):
|
|
77
|
+
return [sanitize(v) for v in o]
|
|
78
|
+
return o
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def call_or_val(v):
|
|
82
|
+
"""The OpenEXR 3.x Part API mixes methods (name/width/height/type/
|
|
83
|
+
compression) and dict properties (channels/header) — normalise both."""
|
|
84
|
+
return v() if callable(v) else v
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def preview_default_name(path, selected_args):
|
|
88
|
+
"""Stable preview filename: <stem>[_<channel-tag>].png, always .png
|
|
89
|
+
(the probe only ever writes PNG). The channel tag keeps previews of
|
|
90
|
+
different channel selections of the same file from overwriting each
|
|
91
|
+
other (e.g. Box-on-alpha_uv_aovs_UV_Box_preview.png)."""
|
|
92
|
+
stem = os.path.splitext(os.path.basename(path))[0]
|
|
93
|
+
if selected_args:
|
|
94
|
+
tag = "_".join(
|
|
95
|
+
re.sub(r"[^a-z0-9]+", "_", s.lower()).strip("_") for s in selected_args
|
|
96
|
+
)
|
|
97
|
+
if tag:
|
|
98
|
+
stem += "_" + tag
|
|
99
|
+
return stem + "_preview.png"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def comp_name(ch_name, arr, i):
|
|
103
|
+
"""Component label: bare channel name for 2-D, 'name.0'/'name.1' for n-D."""
|
|
104
|
+
if arr.ndim == 3:
|
|
105
|
+
return f"{ch_name}.{i}"
|
|
106
|
+
return ch_name
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _auto_mode(lo, hi):
|
|
110
|
+
"""auto decision for one component:
|
|
111
|
+
- ~[0, 1.05] data -> linear min->max (maps/LDR: honest full-range view)
|
|
112
|
+
- image-like HDR (min ~>= 0, max > 1.05) -> tonemap (the viewer look)
|
|
113
|
+
- large negative span (P/Z-like) -> linear full-range (show everything)
|
|
114
|
+
"""
|
|
115
|
+
if hi <= 1.05 and lo >= -0.01:
|
|
116
|
+
return "linear"
|
|
117
|
+
if lo >= -0.01:
|
|
118
|
+
return "tonemap"
|
|
119
|
+
return "linear"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _srgb_gamma(y):
|
|
123
|
+
y = np.clip(y, 0.0, 1.0)
|
|
124
|
+
return np.where(y <= 0.0031308, 12.92 * y, 1.055 * np.power(y, 1.0 / 2.4) - 0.055)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def map_component(a, lo, hi, mode):
|
|
128
|
+
"""Map one component (finite min lo, max hi) to [0, 65535] as float32.
|
|
129
|
+
linear: full [lo, hi] -> [0, 65535] (any data range)
|
|
130
|
+
tonemap: viewer look — 1.0 = white, sRGB gamma, clip above 1 / below 0.
|
|
131
|
+
This is what EXR viewers (Nuke/Photoshop/AE) show, so a vision
|
|
132
|
+
model sees a display-referred image it was trained on."""
|
|
133
|
+
if mode == "linear":
|
|
134
|
+
a = a - lo
|
|
135
|
+
return a / (hi - lo) * 65535.0
|
|
136
|
+
return _srgb_gamma(a) * 65535.0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def channel_matches(cname, selected):
|
|
140
|
+
"""Case-insensitive match of a channel name against the --channel set.
|
|
141
|
+
An exact name match works ("uv", "uv.r"); a GROUP name also selects
|
|
142
|
+
all of a multi-component channel's components ("uv" matches
|
|
143
|
+
"uv.R", "uv.G", "uv.B"). The dot-anchored prefix prevents "uv" from
|
|
144
|
+
matching an unrelated channel named "uvbox"."""
|
|
145
|
+
cl = cname.lower()
|
|
146
|
+
if cl in selected:
|
|
147
|
+
return True
|
|
148
|
+
return any(cl.startswith(s + ".") for s in selected)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def stats_of(a):
|
|
152
|
+
a = a.astype("float32")
|
|
153
|
+
finite = a[np.isfinite(a)]
|
|
154
|
+
if finite.size == 0:
|
|
155
|
+
mn = mx = None
|
|
156
|
+
mean = "NaN"
|
|
157
|
+
else:
|
|
158
|
+
mn, mx = float(finite.min()), float(finite.max())
|
|
159
|
+
mean = float(a.mean()) if a.size else "NaN"
|
|
160
|
+
nz = float(np.count_nonzero(a)) / a.size if a.size else 0.0
|
|
161
|
+
return {"min": mn, "max": mx, "mean": mean, "nonzero_frac": round(nz, 6)}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main():
|
|
165
|
+
ap = argparse.ArgumentParser()
|
|
166
|
+
ap.add_argument("path")
|
|
167
|
+
ap.add_argument("--pixel", nargs=2, type=int, metavar=("X", "Y"))
|
|
168
|
+
ap.add_argument("--rect", nargs=4, type=int, metavar=("X0", "Y0", "X1", "Y1"))
|
|
169
|
+
ap.add_argument(
|
|
170
|
+
"--channel",
|
|
171
|
+
action="append",
|
|
172
|
+
default=None,
|
|
173
|
+
metavar="NAME",
|
|
174
|
+
help="channel name (case-insensitive); a group name like 'uv' also "
|
|
175
|
+
"selects its components ('uv.R','uv.G','uv.B'); may be repeated",
|
|
176
|
+
)
|
|
177
|
+
ap.add_argument("--preview-out", default=None)
|
|
178
|
+
ap.add_argument("--max-preview", type=int, default=1024)
|
|
179
|
+
ap.add_argument(
|
|
180
|
+
"--tone-map",
|
|
181
|
+
choices=["auto", "linear", "tonemap"],
|
|
182
|
+
default="auto",
|
|
183
|
+
help="preview mapping per component (default auto: linear for ~[0,1] data, "
|
|
184
|
+
"Reinhard+sRGB for HDR/negative data)",
|
|
185
|
+
)
|
|
186
|
+
ap.add_argument("--no-preview", action="store_true")
|
|
187
|
+
args = ap.parse_args()
|
|
188
|
+
|
|
189
|
+
path = os.path.abspath(args.path)
|
|
190
|
+
if not os.path.isfile(path):
|
|
191
|
+
fail(f"file not found: {path}")
|
|
192
|
+
|
|
193
|
+
if OpenEXR is None:
|
|
194
|
+
fail(f"missing python dependency: {_IMPORT_ERROR!r}. Install with: pip install OpenEXR")
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
exr = OpenEXR.File(path, separate_channels=True)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
fail(f"cannot open EXR: {e!r}")
|
|
200
|
+
|
|
201
|
+
selected = {c.lower() for c in args.channel} if args.channel else None
|
|
202
|
+
|
|
203
|
+
# ---------- parts + per-channel stats ----------
|
|
204
|
+
parts_out = []
|
|
205
|
+
all_channel_names = set()
|
|
206
|
+
for pi, part in enumerate(exr.parts):
|
|
207
|
+
chdict = call_or_val(part.channels)
|
|
208
|
+
all_channel_names.update(chdict.keys())
|
|
209
|
+
if not chdict:
|
|
210
|
+
fail(f"part {pi} has no channels", parts=parts_out)
|
|
211
|
+
dw = call_or_val(part.header).get("dataWindow")
|
|
212
|
+
dw_out = None
|
|
213
|
+
if dw is not None:
|
|
214
|
+
try:
|
|
215
|
+
lo, hi = dw
|
|
216
|
+
dw_out = [[int(lo[0]), int(lo[1])], [int(hi[0]), int(hi[1])]]
|
|
217
|
+
except Exception:
|
|
218
|
+
dw_out = str(dw)
|
|
219
|
+
channels_out = {}
|
|
220
|
+
for cname, ch in chdict.items():
|
|
221
|
+
arr = np.array(ch.pixels)
|
|
222
|
+
if selected is not None and not channel_matches(cname, selected):
|
|
223
|
+
continue
|
|
224
|
+
comps = {}
|
|
225
|
+
n = arr.shape[2] if arr.ndim == 3 else 1
|
|
226
|
+
for i in range(n):
|
|
227
|
+
a = arr[..., i] if arr.ndim == 3 else arr
|
|
228
|
+
comps[comp_name(cname, arr, i)] = {
|
|
229
|
+
"shape": list(a.shape),
|
|
230
|
+
**stats_of(a),
|
|
231
|
+
}
|
|
232
|
+
channels_out[cname] = comps
|
|
233
|
+
parts_out.append({
|
|
234
|
+
"index": pi,
|
|
235
|
+
"name": str(call_or_val(part.name)),
|
|
236
|
+
"width": int(call_or_val(part.width)),
|
|
237
|
+
"height": int(call_or_val(part.height)),
|
|
238
|
+
"dataWindow": dw_out,
|
|
239
|
+
"storage": str(call_or_val(part.type)),
|
|
240
|
+
"lineOrder": str(call_or_val(part.header).get("lineOrder")),
|
|
241
|
+
"compression": str(call_or_val(part.compression)),
|
|
242
|
+
"channels": channels_out,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
result = {
|
|
246
|
+
"ok": True,
|
|
247
|
+
"path": path,
|
|
248
|
+
"bytes": os.path.getsize(path),
|
|
249
|
+
"numParts": len(parts_out),
|
|
250
|
+
"coordinateConvention": "origin top-left of data window, x right, y down; array index [y, x]",
|
|
251
|
+
"parts": parts_out,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# ---------- channel filter: honest no-match error ----------
|
|
255
|
+
# Runs BEFORE pixel/rect/preview: without it, a filter that matches
|
|
256
|
+
# nothing (a typo, or an old call that assumed exact-match semantics)
|
|
257
|
+
# produced empty results and, worse, a misleading "outside data
|
|
258
|
+
# window" error from the pixel path — verified on a real Arnold AOV
|
|
259
|
+
# file 2026-08-25.
|
|
260
|
+
if selected is not None:
|
|
261
|
+
if not any(channel_matches(c, selected) for c in all_channel_names):
|
|
262
|
+
avail = sorted(all_channel_names)
|
|
263
|
+
fail(
|
|
264
|
+
f"no channel matches {sorted(selected)!r} — available channel names: {avail!r} "
|
|
265
|
+
"(a group name like 'uv' selects its components 'uv.R'/'uv.G'/'uv.B'; "
|
|
266
|
+
"an exact component name also works)",
|
|
267
|
+
available=avail,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# ---------- pixel query ----------
|
|
271
|
+
if args.pixel:
|
|
272
|
+
x, y = args.pixel
|
|
273
|
+
pixel_out = {"x": x, "y": y, "parts": {}}
|
|
274
|
+
found = False
|
|
275
|
+
for p in parts_out:
|
|
276
|
+
if x < 0 or x >= p["width"] or y < 0 or y >= p["height"]:
|
|
277
|
+
continue
|
|
278
|
+
part = exr.parts[p["index"]]
|
|
279
|
+
chdict = call_or_val(part.channels)
|
|
280
|
+
vals = {}
|
|
281
|
+
for cname, ch in chdict.items():
|
|
282
|
+
if selected is not None and not channel_matches(cname, selected):
|
|
283
|
+
continue
|
|
284
|
+
arr = np.array(ch.pixels)
|
|
285
|
+
if arr.ndim == 3:
|
|
286
|
+
for i in range(arr.shape[2]):
|
|
287
|
+
vals[comp_name(cname, arr, i)] = float(arr[y, x, i])
|
|
288
|
+
else:
|
|
289
|
+
vals[cname] = float(arr[y, x])
|
|
290
|
+
if vals:
|
|
291
|
+
pixel_out["parts"][str(p["index"])] = vals
|
|
292
|
+
found = True
|
|
293
|
+
if not found:
|
|
294
|
+
dw = parts_out[0]["dataWindow"]
|
|
295
|
+
fail(
|
|
296
|
+
f"pixel ({x},{y}) outside data window(s) of all parts",
|
|
297
|
+
part0_dataWindow=dw,
|
|
298
|
+
part0_size=[parts_out[0]["width"], parts_out[0]["height"]],
|
|
299
|
+
)
|
|
300
|
+
result["pixel"] = pixel_out
|
|
301
|
+
|
|
302
|
+
# ---------- rect stats ----------
|
|
303
|
+
if args.rect:
|
|
304
|
+
x0, y0, x1, y1 = args.rect
|
|
305
|
+
if x0 > x1 or y0 > y1:
|
|
306
|
+
fail("invalid rect: x0>x1 or y0>y1")
|
|
307
|
+
p0 = parts_out[0]
|
|
308
|
+
part = exr.parts[0]
|
|
309
|
+
chdict = call_or_val(part.channels)
|
|
310
|
+
rect_out = {"x0": x0, "y0": y0, "x1": x1, "y1": y1, "size": [x1 - x0 + 1, y1 - y0 + 1], "channels": {}}
|
|
311
|
+
if x0 < 0 or y0 < 0 or x1 >= p0["width"] or y1 >= p0["height"]:
|
|
312
|
+
fail(
|
|
313
|
+
f"rect ({x0},{y0})-({x1},{y1}) outside part 0 data window "
|
|
314
|
+
f"(0,0)-({p0['width']-1},{p0['height']-1})"
|
|
315
|
+
)
|
|
316
|
+
for cname, ch in chdict.items():
|
|
317
|
+
if selected is not None and not channel_matches(cname, selected):
|
|
318
|
+
continue
|
|
319
|
+
arr = np.array(ch.pixels)[y0 : y1 + 1, x0 : x1 + 1]
|
|
320
|
+
n = arr.shape[2] if arr.ndim == 3 else 1
|
|
321
|
+
rect_out["channels"][cname] = {}
|
|
322
|
+
for i in range(n):
|
|
323
|
+
a = arr[..., i] if arr.ndim == 3 else arr
|
|
324
|
+
rect_out["channels"][cname][comp_name(cname, arr, i)] = stats_of(a)
|
|
325
|
+
result["rect"] = rect_out
|
|
326
|
+
|
|
327
|
+
# ---------- preview ----------
|
|
328
|
+
if not args.no_preview:
|
|
329
|
+
try:
|
|
330
|
+
from PIL import Image
|
|
331
|
+
except ImportError:
|
|
332
|
+
result["preview"] = {
|
|
333
|
+
"skipped": "PIL not installed (pip install Pillow); no preview produced"
|
|
334
|
+
}
|
|
335
|
+
else:
|
|
336
|
+
p0 = parts_out[0]
|
|
337
|
+
part = exr.parts[0]
|
|
338
|
+
chdict = call_or_val(part.channels)
|
|
339
|
+
if selected is not None:
|
|
340
|
+
names = [c for c in chdict if channel_matches(c, selected)]
|
|
341
|
+
else:
|
|
342
|
+
# Named R/G/B channels first; file order is NOT a safe
|
|
343
|
+
# default (this Arnold file lists A, B, G, R), otherwise
|
|
344
|
+
# fall back to the first up-to-3 channels.
|
|
345
|
+
rgb = [c for c in chdict if c.lower() in ("r", "g", "b")]
|
|
346
|
+
names = rgb if rgb else list(chdict.keys())[:3]
|
|
347
|
+
# R,G,B-aware slot ordering for whatever was picked: a channel
|
|
348
|
+
# whose name (or .suffix) is r/g/b goes to the matching color
|
|
349
|
+
# slot in R,G,B order regardless of file channel order — file
|
|
350
|
+
# order put B in the red slot for a group-selected UV AOV, the
|
|
351
|
+
# same swap as the 2026-08-25 preview bug. Non-RGB channels
|
|
352
|
+
# keep file order (stable). This is what makes a UV AOV preview
|
|
353
|
+
# read U->red, V->green.
|
|
354
|
+
_order = {c: i for i, c in enumerate(chdict.keys())}
|
|
355
|
+
|
|
356
|
+
def _slot_key(c):
|
|
357
|
+
lc = c.lower()
|
|
358
|
+
for i, s in enumerate(("r", "g", "b")):
|
|
359
|
+
if lc == s or lc.endswith("." + s):
|
|
360
|
+
return (0, i, 0)
|
|
361
|
+
return (1, 0, _order[c])
|
|
362
|
+
|
|
363
|
+
names.sort(key=_slot_key)
|
|
364
|
+
if not names:
|
|
365
|
+
result["preview"] = {"skipped": "no channels available for preview"}
|
|
366
|
+
else:
|
|
367
|
+
out_path = (
|
|
368
|
+
os.path.abspath(args.preview_out)
|
|
369
|
+
if args.preview_out
|
|
370
|
+
else os.path.join(os.getcwd(), "images", "previews", preview_default_name(path, args.channel))
|
|
371
|
+
)
|
|
372
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
373
|
+
arrs = {c: np.array(chdict[c].pixels).astype("float32") for c in names}
|
|
374
|
+
w, h = arrs[names[0]].shape[1], arrs[names[0]].shape[0]
|
|
375
|
+
scales = {}
|
|
376
|
+
modes = {}
|
|
377
|
+
|
|
378
|
+
def _mode_for(lo, hi):
|
|
379
|
+
return args.tone_map if args.tone_map != "auto" else _auto_mode(lo, hi)
|
|
380
|
+
|
|
381
|
+
if names[0] in arrs and arrs[names[0]].ndim == 3:
|
|
382
|
+
# one multi-component channel: comp i -> channel i of RGB
|
|
383
|
+
comp_arrays, labels = [], []
|
|
384
|
+
for i in range(min(3, arrs[names[0]].shape[2])):
|
|
385
|
+
a = arrs[names[0]][..., i]
|
|
386
|
+
f = a[np.isfinite(a)]
|
|
387
|
+
lo, hi = (float(f.min()), float(f.max())) if f.size else (0.0, 1.0)
|
|
388
|
+
if hi <= lo:
|
|
389
|
+
hi = lo + 1.0
|
|
390
|
+
m = _mode_for(lo, hi)
|
|
391
|
+
scales[f"{names[0]}.{i}"] = [lo, hi]
|
|
392
|
+
modes[f"{names[0]}.{i}"] = m
|
|
393
|
+
comp_arrays.append(map_component(a, lo, hi, m))
|
|
394
|
+
labels.append(f"{names[0]}.{i}")
|
|
395
|
+
rgb = np.stack(
|
|
396
|
+
[np.clip(comp_arrays[0], 0, 65535)]
|
|
397
|
+
+ ([np.clip(comp_arrays[1], 0, 65535)] if len(comp_arrays) > 1 else [np.zeros_like(comp_arrays[0])])
|
|
398
|
+
+ ([np.clip(comp_arrays[2], 0, 65535)] if len(comp_arrays) > 2 else [np.zeros_like(comp_arrays[0])]),
|
|
399
|
+
axis=-1,
|
|
400
|
+
) # keep float in [0, 65535]; 8-bit conversion happens below
|
|
401
|
+
else:
|
|
402
|
+
comp_arrays, labels = [], []
|
|
403
|
+
for c in names[:3]:
|
|
404
|
+
a = arrs[c] if arrs[c].ndim == 2 else arrs[c][..., 0]
|
|
405
|
+
f = a[np.isfinite(a)]
|
|
406
|
+
lo, hi = (float(f.min()), float(f.max())) if f.size else (0.0, 1.0)
|
|
407
|
+
if hi <= lo:
|
|
408
|
+
hi = lo + 1.0
|
|
409
|
+
m = _mode_for(lo, hi)
|
|
410
|
+
scales[c] = [lo, hi]
|
|
411
|
+
modes[c] = m
|
|
412
|
+
comp_arrays.append(map_component(a, lo, hi, m))
|
|
413
|
+
labels.append(c)
|
|
414
|
+
rgb = np.stack(
|
|
415
|
+
comp_arrays[0:1]
|
|
416
|
+
+ (comp_arrays[1:2] if len(comp_arrays) > 1 else [np.zeros_like(comp_arrays[0])])
|
|
417
|
+
+ (comp_arrays[2:3] if len(comp_arrays) > 2 else [np.zeros_like(comp_arrays[0])]),
|
|
418
|
+
axis=-1,
|
|
419
|
+
) # keep float in [0, 65535]; 8-bit conversion happens below
|
|
420
|
+
# Convert to 8-bit HERE, explicitly. Pillow cannot encode 16-bit
|
|
421
|
+
# RGB PNGs, and Image.fromarray(uint16_array, "RGB") does NOT
|
|
422
|
+
# downscale — it reinterprets the raw 2-byte values as 8-bit
|
|
423
|
+
# byte pairs (verified 2026-08-25: produced a scrambled image).
|
|
424
|
+
rgb = (rgb / 257.0).round().clip(0, 255).astype("uint8")
|
|
425
|
+
im = Image.fromarray(rgb, mode="RGB")
|
|
426
|
+
max_edge = max(args.max_preview, 2)
|
|
427
|
+
if max(h, w) > max_edge:
|
|
428
|
+
scale = max_edge / float(max(h, w))
|
|
429
|
+
im = im.resize((max(2, int(w * scale)), max(2, int(h * scale))), Image.LANCZOS)
|
|
430
|
+
im.save(out_path, "PNG")
|
|
431
|
+
result["preview"] = {
|
|
432
|
+
"path": out_path,
|
|
433
|
+
"size": [im.width, im.height],
|
|
434
|
+
"bitDepth": "8-bit RGB",
|
|
435
|
+
"slots": {s: labels[i] for i, s in enumerate(("R", "G", "B")) if i < len(labels)},
|
|
436
|
+
"mapping": {k: modes.get(k, "linear") for k in scales},
|
|
437
|
+
"componentScales": {
|
|
438
|
+
k: {"from": v, "to": [0, 255]} for k, v in scales.items()
|
|
439
|
+
},
|
|
440
|
+
"note": (
|
|
441
|
+
"per-component mapping: 'linear' = full [min,max] -> full range (LDR/maps); "
|
|
442
|
+
"'tonemap' = shift+Reinhard+sRGB gamma (HDR/negative data, for viewing). "
|
|
443
|
+
"Structure shown, absolute values NOT — use --pixel/--rect for values. "
|
|
444
|
+
f"(requested --tone-map: {args.tone_map})"
|
|
445
|
+
),
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
print(json.dumps(sanitize(result)))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
if __name__ == "__main__":
|
|
452
|
+
try:
|
|
453
|
+
main()
|
|
454
|
+
except SystemExit:
|
|
455
|
+
raise
|
|
456
|
+
except Exception as e:
|
|
457
|
+
fail(f"unexpected error: {e!r}")
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exr_read — EXR inspection tool for pi.
|
|
3
|
+
*
|
|
4
|
+
* Loads .exr files (Arnold/RenderMan/Blender/USD renders, UV/ST maps,
|
|
5
|
+
* AOVs, multilayer EXRs) and reports them to the LLM as:
|
|
6
|
+
* - part metadata (size, data window, storage, compression, line order)
|
|
7
|
+
* - per-channel statistics (min/max/mean/nonzero fraction, per component)
|
|
8
|
+
* - exact float pixel values at a point
|
|
9
|
+
* - per-channel statistics over a rect
|
|
10
|
+
* - an 8-bit linear-mapped PNG preview (attached to the tool result,
|
|
11
|
+
* so a vision model can see the structure)
|
|
12
|
+
*
|
|
13
|
+
* The heavy lifting is done by exr_reader/exr_probe.py (OpenEXR 3.x
|
|
14
|
+
* Python API + numpy + Pillow). This extension only: locates the script,
|
|
15
|
+
* builds the command, runs it, and packages JSON + preview image.
|
|
16
|
+
*
|
|
17
|
+
* Coordinate convention: (x, y) with origin at the TOP-LEFT of the part's
|
|
18
|
+
* data window, x right, y DOWN — matching how the preview PNG is oriented.
|
|
19
|
+
*
|
|
20
|
+
* Project-local install: <project>/.pi/extensions/exr_read/index.ts
|
|
21
|
+
* (add { "extensions": ["../../<abs path>/exr_reader/index.ts"] } to
|
|
22
|
+
* <project>/.pi/settings.json if you keep the code elsewhere)
|
|
23
|
+
* Global install: copy to ~/.pi/agent/extensions/exr_read/index.ts
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { Type } from "typebox";
|
|
28
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
29
|
+
import { basename, join, resolve } from "node:path";
|
|
30
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
31
|
+
|
|
32
|
+
const SCRIPT_DIR = __dirname;
|
|
33
|
+
const SCRIPT = join(SCRIPT_DIR, "exr_probe.py");
|
|
34
|
+
const MAX_PREVIEW_PX = 2048; // 1080² originals pass through unresized; cap guards huge renders
|
|
35
|
+
const MAX_TEXT_CHARS = 12000;
|
|
36
|
+
|
|
37
|
+
// Mirrors preview_default_name() in exr_probe.py (kept in sync on purpose):
|
|
38
|
+
// <stem>[_<channel-tag>]_preview.png — the channel tag keeps previews of
|
|
39
|
+
// different channel selections of the same file from overwriting each
|
|
40
|
+
// other. The extension always passes --preview-out explicitly (user value
|
|
41
|
+
// or this default) so it knows the exact path for the mutation queue.
|
|
42
|
+
function previewDefaultName(exrPath: string, channels: string[] | undefined): string {
|
|
43
|
+
let stem = basename(exrPath).replace(/\.[^.]*$/, "");
|
|
44
|
+
if (channels?.length) {
|
|
45
|
+
const tag = channels
|
|
46
|
+
.map((s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""))
|
|
47
|
+
.join("_");
|
|
48
|
+
if (tag) stem += "_" + tag;
|
|
49
|
+
}
|
|
50
|
+
return stem + "_preview.png";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findPython(): string | null {
|
|
54
|
+
const candidates = process.platform === "win32" ? ["python", "python3"] : ["python3", "python"];
|
|
55
|
+
for (const c of candidates) {
|
|
56
|
+
try {
|
|
57
|
+
const r = spawnSync(c, ["-c", "import OpenEXR, numpy; print(OpenEXR.__version__)"], {
|
|
58
|
+
timeout: 15000,
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
});
|
|
62
|
+
if (r.status === 0 && (r.stdout ?? "").trim()) return c;
|
|
63
|
+
} catch {
|
|
64
|
+
/* try next */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const exrReadSchema = Type.Object({
|
|
71
|
+
path: Type.String({ description: "Path to the .exr file (absolute or relative to the session cwd)." }),
|
|
72
|
+
pixel: Type.Optional(
|
|
73
|
+
Type.Tuple([Type.Number(), Type.Number()], {
|
|
74
|
+
description:
|
|
75
|
+
"Optional: query exact values at pixel (x, y). Origin is TOP-LEFT of the part's data window, x right, y DOWN (preview PNG orientation).",
|
|
76
|
+
})
|
|
77
|
+
),
|
|
78
|
+
rect: Type.Optional(
|
|
79
|
+
Type.Tuple([Type.Number(), Type.Number(), Type.Number(), Type.Number()], {
|
|
80
|
+
description: "Optional: per-channel stats over rect (x0, y0, x1, y1), inclusive corners.",
|
|
81
|
+
})
|
|
82
|
+
),
|
|
83
|
+
channel: Type.Optional(
|
|
84
|
+
Type.Array(Type.String(), {
|
|
85
|
+
description:
|
|
86
|
+
'Optional: restrict values/stats/preview to these channels (case-insensitive). A group name selects all its components ("uv" matches "uv.R","uv.G","uv.B"; "UV_Box" matches "UV_Box.R/.G/.B"); an exact component name also works. Omitted = all channels for stats, RGB-like composition for preview. A filter that matches nothing is an error listing the available channel names.',
|
|
87
|
+
})
|
|
88
|
+
),
|
|
89
|
+
preview: Type.Optional(
|
|
90
|
+
Type.Boolean({ description: "Optional: set false to skip the PNG preview (default true)." })
|
|
91
|
+
),
|
|
92
|
+
previewToneMap: Type.Optional(
|
|
93
|
+
Type.Union([Type.Literal("auto"), Type.Literal("linear"), Type.Literal("tonemap")], {
|
|
94
|
+
description:
|
|
95
|
+
"Optional: preview mapping per component (default 'auto'). 'linear' = min->max full-range remap; 'tonemap' = the viewer look (1.0 = white, sRGB gamma, clip above — what Nuke/Photoshop/AE show for an EXR); 'auto' = linear for map/LDR-like data (~[0,1]) and for data with large negatives (P/Z), tonemap for image-like HDR.",
|
|
96
|
+
})
|
|
97
|
+
),
|
|
98
|
+
previewOut: Type.Optional(
|
|
99
|
+
Type.String({
|
|
100
|
+
description:
|
|
101
|
+
"Optional: where to save the preview PNG. Default: <cwd>/images/previews/<filename>[_<channels>]_preview.png (project-local, stable names, re-runs refresh the same file).",
|
|
102
|
+
})
|
|
103
|
+
),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export type ExrReadInput = {
|
|
107
|
+
path: string;
|
|
108
|
+
pixel?: [number, number];
|
|
109
|
+
rect?: [number, number, number, number];
|
|
110
|
+
channel?: string[];
|
|
111
|
+
preview?: boolean;
|
|
112
|
+
previewToneMap?: "auto" | "linear" | "tonemap";
|
|
113
|
+
previewOut?: string;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function previewNote(hasVision: boolean, mapping: Record<string, string> | undefined): string {
|
|
117
|
+
const coord =
|
|
118
|
+
"Coordinates: (x, y) with origin at the TOP-LEFT of the part's data window, x increasing right, y increasing DOWN — the preview PNG is oriented the same way, so a point you see at (px, py) in the preview is queryable as pixel [px, py] (scale by preview.size / part size first if the preview was downscaled).";
|
|
119
|
+
const map = mapping && Object.values(mapping).length
|
|
120
|
+
? ` Mapping per component (see 'mapping' in JSON): ${JSON.stringify(mapping)} — 'tonemap' components are Reinhard+sRGB, for viewing only.`
|
|
121
|
+
: "";
|
|
122
|
+
return hasVision
|
|
123
|
+
? `Preview PNG: an 8-bit view of the image structure (LDR/map components are a linear min->max remap, HDR components are tone-mapped${map}) — structure is shown, absolute values are NOT; use pixel/rect for values. ${coord}`
|
|
124
|
+
: `Preview: the current model has no image input, so no preview was attached — the JSON below is the full data (the preview PNG is still written for you). ${coord}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export default function (pi: any) {
|
|
128
|
+
let pythonCache: string | null | undefined;
|
|
129
|
+
|
|
130
|
+
pi.registerTool({
|
|
131
|
+
name: "exr_read",
|
|
132
|
+
label: "EXR Read",
|
|
133
|
+
description:
|
|
134
|
+
"Read an .exr image file (render passes, UV/ST maps, Arnold AOVs such as UV/P/Z/TextureGrid, multilayer EXR). Returns part metadata + per-channel float stats, exact pixel values at a point, per-channel stats over a rect, and a linear-mapped PNG preview. Use it whenever the user mentions or references an .exr file. AOVs: pass the group name (e.g. \"UV_Box\") as channel; pixel queries report every part of a multilayer file.",
|
|
135
|
+
promptSnippet: "exr_read(path, pixel?, rect?, channel?, preview?) — inspect .exr files: metadata, per-channel stats, exact pixel values, rect stats, PNG preview",
|
|
136
|
+
promptGuidelines: [
|
|
137
|
+
"Use exr_read (not read/bash) for .exr files; it reports full-precision channel values.",
|
|
138
|
+
"exr_read coordinates: origin top-left, y down — same orientation as its preview PNG.",
|
|
139
|
+
"exr_read channel filter: a group name (e.g. 'uv_box') selects its components; a non-matching filter is an error that lists the available names.",
|
|
140
|
+
],
|
|
141
|
+
parameters: exrReadSchema,
|
|
142
|
+
async execute(
|
|
143
|
+
_toolCallId: string,
|
|
144
|
+
params: ExrReadInput,
|
|
145
|
+
signal: AbortSignal | undefined,
|
|
146
|
+
_onUpdate: unknown,
|
|
147
|
+
ctx: any
|
|
148
|
+
) {
|
|
149
|
+
if (pythonCache === undefined) pythonCache = findPython();
|
|
150
|
+
const python = pythonCache;
|
|
151
|
+
if (!python) {
|
|
152
|
+
return {
|
|
153
|
+
content: [
|
|
154
|
+
{
|
|
155
|
+
type: "text" as const,
|
|
156
|
+
text: "exr_read unavailable: no python with OpenEXR + numpy found on PATH. Install with: pip install OpenEXR numpy Pillow",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
isError: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const script = SCRIPT;
|
|
164
|
+
if (!existsSync(script)) {
|
|
165
|
+
return {
|
|
166
|
+
content: [{ type: "text" as const, text: `exr_read: script missing: ${script}` }],
|
|
167
|
+
isError: true,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const absPath = resolve(ctx.cwd, params.path);
|
|
172
|
+
if (!existsSync(absPath)) {
|
|
173
|
+
return {
|
|
174
|
+
content: [{ type: "text" as const, text: `exr_read: file not found: ${absPath}` }],
|
|
175
|
+
isError: true,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const args: string[] = [script, absPath];
|
|
179
|
+
if (params.pixel) args.push("--pixel", String(params.pixel[0]), String(params.pixel[1]));
|
|
180
|
+
if (params.rect)
|
|
181
|
+
args.push(
|
|
182
|
+
"--rect",
|
|
183
|
+
String(params.rect[0]),
|
|
184
|
+
String(params.rect[1]),
|
|
185
|
+
String(params.rect[2]),
|
|
186
|
+
String(params.rect[3])
|
|
187
|
+
);
|
|
188
|
+
if (params.channel) for (const c of params.channel) args.push("--channel", c);
|
|
189
|
+
const wantPreview = params.preview !== false;
|
|
190
|
+
let previewPath: string | undefined;
|
|
191
|
+
if (!wantPreview) {
|
|
192
|
+
args.push("--no-preview");
|
|
193
|
+
} else {
|
|
194
|
+
previewPath = params.previewOut
|
|
195
|
+
? resolve(ctx.cwd, params.previewOut)
|
|
196
|
+
: join(ctx.cwd, "images", "previews", previewDefaultName(absPath, params.channel));
|
|
197
|
+
args.push(
|
|
198
|
+
"--preview-out", previewPath,
|
|
199
|
+
"--max-preview", String(MAX_PREVIEW_PX),
|
|
200
|
+
"--tone-map", params.previewToneMap ?? "auto"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The probe writes previewPath. Queue it per the extension docs so
|
|
205
|
+
// parallel exr_read calls on the same file (same default preview
|
|
206
|
+
// path) can't interleave their writes.
|
|
207
|
+
const run = () => new Promise<{ stdout: string; stderr: string; code: number; spawnErr?: string }>((resolveP) => {
|
|
208
|
+
execFile(
|
|
209
|
+
python,
|
|
210
|
+
args,
|
|
211
|
+
{
|
|
212
|
+
cwd: ctx.cwd,
|
|
213
|
+
timeout: 300000,
|
|
214
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
215
|
+
windowsHide: true,
|
|
216
|
+
signal,
|
|
217
|
+
},
|
|
218
|
+
(error, stdout, stderr) => {
|
|
219
|
+
let code = 0;
|
|
220
|
+
let spawnErr: string | undefined;
|
|
221
|
+
if (error) {
|
|
222
|
+
if (typeof (error as any).code === "number") code = (error as any).code;
|
|
223
|
+
else spawnErr = (error as any).code ?? String(error);
|
|
224
|
+
}
|
|
225
|
+
resolveP({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), code, spawnErr });
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const res = previewPath ? await withFileMutationQueue(previewPath, run) : await run();
|
|
231
|
+
|
|
232
|
+
if (res.spawnErr) {
|
|
233
|
+
return {
|
|
234
|
+
content: [{ type: "text" as const, text: `exr_read: python spawn failed (${res.spawnErr})` }],
|
|
235
|
+
isError: true,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (res.code !== 0 && !res.stdout.trim()) {
|
|
239
|
+
return {
|
|
240
|
+
content: [{ type: "text" as const, text: `exr_read failed (exit ${res.code}):\n${res.stderr}` }],
|
|
241
|
+
isError: true,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let json: any = null;
|
|
246
|
+
try {
|
|
247
|
+
json = JSON.parse(res.stdout.trim().split("\n").pop() ?? "");
|
|
248
|
+
} catch (e: any) {
|
|
249
|
+
return {
|
|
250
|
+
content: [{ type: "text" as const, text: `exr_read: could not parse probe output: ${e?.message}\n${res.stdout.slice(0, 2000)}` }],
|
|
251
|
+
isError: true,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!json.ok) {
|
|
256
|
+
return {
|
|
257
|
+
content: [{ type: "text" as const, text: `exr_read: ${json.error}${json.part0_dataWindow ? ` (part 0 data window ${JSON.stringify(json.part0_dataWindow)}, size ${JSON.stringify(json.part0_size)})` : ""}` }],
|
|
258
|
+
isError: true,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const model: any = ctx.model;
|
|
263
|
+
const hasVision = Array.isArray(model?.input) && model.input.includes("image");
|
|
264
|
+
|
|
265
|
+
const content: Array<{ type: "text" | "image"; text?: string; data?: string; mimeType?: string }> = [];
|
|
266
|
+
let text = JSON.stringify(json, null, 1);
|
|
267
|
+
if (text.length > MAX_TEXT_CHARS) {
|
|
268
|
+
text =
|
|
269
|
+
text.slice(0, MAX_TEXT_CHARS) +
|
|
270
|
+
`\n…[truncated ${text.length - MAX_TEXT_CHARS} chars — re-query with channel/pixel/rect to narrow]`;
|
|
271
|
+
}
|
|
272
|
+
if (hasVision && json.preview?.path && existsSync(json.preview.path) && statSync(json.preview.path).size > 0) {
|
|
273
|
+
content.push({
|
|
274
|
+
type: "image",
|
|
275
|
+
data: readFileSync(json.preview.path).toString("base64"),
|
|
276
|
+
mimeType: "image/png",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
content.push({ type: "text", text: text + "\n\n" + previewNote(hasVision, json.preview?.mapping) });
|
|
280
|
+
|
|
281
|
+
return { content, details: { previewPath: json.preview?.path } };
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-exr-reader",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: read .exr files — exact full-precision channel stats, pixel values, rect stats, and a PNG preview. Works on Arnold/RenderMan/Blender AOVs, UV/ST maps, and multilayer EXRs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi",
|
|
9
|
+
"exr",
|
|
10
|
+
"openexr",
|
|
11
|
+
"aov",
|
|
12
|
+
"arnold",
|
|
13
|
+
"uv-map",
|
|
14
|
+
"render",
|
|
15
|
+
"pixel-inspection"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Luke Richardson",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/lrich0/pi-exr-reader.git"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"exr_reader/",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./exr_reader"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
34
|
+
"typebox": "*"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
38
|
+
"@types/node": "^25.9.1",
|
|
39
|
+
"typebox": "*",
|
|
40
|
+
"typescript": "^6.0.3"
|
|
41
|
+
}
|
|
42
|
+
}
|