@dpantani/tdmcp 0.1.0 → 0.2.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.
@@ -11,8 +11,42 @@ def connect(source_path, target_path, source_output=0, target_input=0):
11
11
  src = op(source_path)
12
12
  dst = op(target_path)
13
13
  if src is None or dst is None:
14
- raise LookupError("Source or target not found")
15
- dst.inputConnectors[target_input].connect(src.outputConnectors[source_output])
14
+ raise LookupError("Source or target not found: %s -> %s" % (source_path, target_path))
15
+ src_parent = src.parent()
16
+ dst_parent = dst.parent()
17
+ # TD wires only connect operators sharing a parent. A cross-container connect
18
+ # silently no-ops (no exception, no wire), so reject it with an actionable msg.
19
+ if src_parent is None or dst_parent is None or src_parent.path != dst_parent.path:
20
+ raise ValueError(
21
+ "Cannot wire across containers: %s (in %s) -> %s (in %s). "
22
+ "Wires only connect operators sharing a parent; to bring an operator "
23
+ "across networks use a Select/In OP (e.g. a Select TOP/CHOP whose source "
24
+ "parameter points at %r)."
25
+ % (
26
+ src.path,
27
+ getattr(src_parent, "path", "<root>"),
28
+ dst.path,
29
+ getattr(dst_parent, "path", "<root>"),
30
+ source_path,
31
+ )
32
+ )
33
+ try:
34
+ in_conn = dst.inputConnectors[target_input]
35
+ out_conn = src.outputConnectors[source_output]
36
+ except IndexError:
37
+ raise IndexError(
38
+ "Connector index out of range: target_input=%d (%s has %d input(s)), "
39
+ "source_output=%d (%s has %d output(s))."
40
+ % (
41
+ target_input,
42
+ dst.path,
43
+ len(dst.inputConnectors),
44
+ source_output,
45
+ src.path,
46
+ len(src.outputConnectors),
47
+ )
48
+ )
49
+ in_conn.connect(out_conn)
16
50
 
17
51
 
18
52
  def run(operations):
@@ -1,4 +1,10 @@
1
- """Capture a TOP as a base64-encoded PNG."""
1
+ """Capture a TOP as a base64-encoded PNG.
2
+
3
+ The TOP is composited over a checkerboard first, so transparent regions read as a
4
+ checker pattern instead of solid white when a viewer flattens the PNG's alpha over a
5
+ white page. Opaque TOPs are unaffected (the checker stays fully hidden). If the
6
+ composite path errors for any reason, we fall back to saving the TOP directly.
7
+ """
2
8
 
3
9
  import base64
4
10
 
@@ -6,14 +12,18 @@ import td
6
12
 
7
13
  op = td.op # TD globals are not available inside imported modules; reach via td
8
14
 
15
+ # Mid-gray checkerboard (16x9 cells). Self-contained fragment shader, no inputs/uniforms.
16
+ _CHECKER_FRAG = """out vec4 fragColor;
17
+ void main(){
18
+ vec2 cell = floor(vUV.st * vec2(16.0, 9.0));
19
+ float k = mod(cell.x + cell.y, 2.0);
20
+ fragColor = vec4(mix(vec3(0.16), vec3(0.30), k), 1.0);
21
+ }
22
+ """
9
23
 
10
- def capture(path, width=640, height=360):
11
- node = op(path)
12
- if node is None:
13
- raise LookupError("Node not found: %s" % path)
14
- if getattr(node, "family", None) != "TOP":
15
- raise ValueError("Preview is only supported for TOPs, got %s" % path)
16
24
 
25
+ def _save_png(node):
26
+ """Return the node's PNG bytes, via saveByteArray with a temp-file fallback."""
17
27
  data = None
18
28
  try:
19
29
  data = node.saveByteArray(".png")
@@ -21,7 +31,6 @@ def capture(path, width=640, height=360):
21
31
  data = None
22
32
 
23
33
  if data is None:
24
- # Fallback: save to a temp file and read it back.
25
34
  import os
26
35
  import tempfile
27
36
 
@@ -30,11 +39,75 @@ def capture(path, width=640, height=360):
30
39
  with open(tmp, "rb") as handle:
31
40
  data = handle.read()
32
41
 
33
- encoded = base64.b64encode(bytes(data)).decode("ascii")
42
+ return bytes(data)
43
+
44
+
45
+ def _checkerboard_png(node, width, height):
46
+ """Composite `node` over a checkerboard and return the flattened PNG bytes.
47
+
48
+ Creates a few temporary nodes in the node's parent and destroys them afterwards
49
+ (even on error). Returns None if the composite could not be produced, so callers
50
+ can fall back to a direct save.
51
+ """
52
+ parent = node.parent()
53
+ if parent is None:
54
+ return None
55
+
56
+ temps = []
57
+ try:
58
+ frag = parent.create("textDAT", "__tdmcp_pv_frag")
59
+ temps.append(frag)
60
+ frag.text = _CHECKER_FRAG
61
+
62
+ bg = parent.create("glslTOP", "__tdmcp_pv_bg")
63
+ temps.append(bg)
64
+ bg.par.pixeldat = frag.name
65
+ bg.par.outputresolution = "custom"
66
+ bg.par.resolutionw = width
67
+ bg.par.resolutionh = height
68
+
69
+ comp = parent.create("compositeTOP", "__tdmcp_pv_comp")
70
+ temps.append(comp)
71
+ comp.par.operand = "over"
72
+ comp.par.outputresolution = "custom"
73
+ comp.par.resolutionw = width
74
+ comp.par.resolutionh = height
75
+ comp.inputConnectors[0].connect(node) # foreground (over)
76
+ comp.inputConnectors[1].connect(bg) # background (under)
77
+ comp.cook(force=True)
78
+
79
+ if comp.errors():
80
+ return None
81
+ return _save_png(comp)
82
+ except Exception: # noqa: BLE001
83
+ return None
84
+ finally:
85
+ for t in reversed(temps):
86
+ try:
87
+ t.destroy()
88
+ except Exception: # noqa: BLE001
89
+ pass
90
+
91
+
92
+ def capture(path, width=640, height=360):
93
+ node = op(path)
94
+ if node is None:
95
+ raise LookupError("Node not found: %s" % path)
96
+ if getattr(node, "family", None) != "TOP":
97
+ raise ValueError("Preview is only supported for TOPs, got %s" % path)
98
+
99
+ w = int(getattr(node, "width", width) or width)
100
+ h = int(getattr(node, "height", height) or height)
101
+
102
+ data = _checkerboard_png(node, w, h)
103
+ if data is None:
104
+ data = _save_png(node)
105
+
106
+ encoded = base64.b64encode(data).decode("ascii")
34
107
  return {
35
108
  "path": node.path,
36
- "width": int(getattr(node, "width", width) or width),
37
- "height": int(getattr(node, "height", height) or height),
109
+ "width": w,
110
+ "height": h,
38
111
  "format": "png",
39
112
  "base64": encoded,
40
113
  }
@@ -202,6 +202,40 @@ class ParsingTests(unittest.TestCase):
202
202
  self.assertIsNone(ac._find_header({"other": "v"}, "x-token"))
203
203
 
204
204
 
205
+ class OriginTests(unittest.TestCase):
206
+ """The Origin guard blocks browser-driven CSRF / DNS-rebinding."""
207
+
208
+ def test_no_origin_is_allowed(self):
209
+ ac._check_origin({"method": "GET"}) # the Node client sends no Origin
210
+
211
+ def test_loopback_origins_allowed(self):
212
+ for origin in (
213
+ "http://127.0.0.1:9980",
214
+ "http://localhost",
215
+ "https://localhost:3000",
216
+ "http://[::1]:9980",
217
+ ):
218
+ ac._check_origin({"Origin": origin}) # must not raise
219
+
220
+ def test_cross_origin_rejected(self):
221
+ for origin in ("http://evil.com", "https://attacker.example:8443", "http://192.168.1.5"):
222
+ with self.assertRaises(PermissionError, msg=origin):
223
+ ac._check_origin({"Origin": origin})
224
+
225
+ def test_opaque_null_origin_rejected(self):
226
+ with self.assertRaises(PermissionError):
227
+ ac._check_origin({"Origin": "null"})
228
+
229
+ def test_origin_lookup_is_case_insensitive_and_nested(self):
230
+ with self.assertRaises(PermissionError):
231
+ ac._check_origin({"headers": {"origin": "http://evil.com"}})
232
+
233
+ def test_handle_rejects_cross_origin_with_401(self):
234
+ resp = ac.handle({"method": "GET", "uri": "/api/info", "Origin": "http://evil.com"}, {})
235
+ self.assertEqual(resp["statusCode"], 401)
236
+ self.assertIn("cross-origin", resp["data"])
237
+
238
+
205
239
  class HandleTests(unittest.TestCase):
206
240
  def tearDown(self):
207
241
  _clear_exec_env()
@@ -0,0 +1,209 @@
1
+ """Unit tests for the bridge service layer: parameter validation + connect guards.
2
+
3
+ Exercises api_service.update_parameters / create_node and batch_service.connect
4
+ off-TD using lightweight fakes for TD node objects. These lock in the fixes for
5
+ two silent-failure bugs:
6
+
7
+ * setting an unknown parameter name (e.g. `gain` on a levelTOP) used to be
8
+ silently dropped; it now raises (update) or warns (create).
9
+ * wiring two operators in different containers used to silently no-op; it now
10
+ raises with an actionable message.
11
+
12
+ Stdlib only. Run: `python3 -m unittest discover -s td/tests` (or `npm run test:bridge`).
13
+ """
14
+
15
+ import os
16
+ import sys
17
+ import types
18
+ import unittest
19
+ from unittest import mock
20
+
21
+ # --- Make the bridge importable without TouchDesigner --------------------------
22
+ _HERE = os.path.dirname(os.path.abspath(__file__))
23
+ _MODULES = os.path.abspath(os.path.join(_HERE, "..", "modules"))
24
+ if _MODULES not in sys.path:
25
+ sys.path.insert(0, _MODULES)
26
+
27
+ # `mcp.services.*` bind `op/app/project` from `td` at import time; a stub suffices.
28
+ _td_stub = types.ModuleType("td")
29
+ _td_stub.op = mock.MagicMock(name="op")
30
+ _td_stub.app = mock.MagicMock(name="app")
31
+ _td_stub.project = mock.MagicMock(name="project")
32
+ sys.modules.setdefault("td", _td_stub)
33
+
34
+ from mcp.services import analysis_service, api_service, batch_service # noqa: E402
35
+
36
+
37
+ # --- Lightweight fakes for TD node objects -------------------------------------
38
+ class FakePar:
39
+ def __init__(self, name, val=None):
40
+ self.name = name
41
+ self.val = val
42
+
43
+ def eval(self):
44
+ return self.val
45
+
46
+
47
+ class FakeParCollection:
48
+ """getattr(self, name, None) -> FakePar for known names, None otherwise."""
49
+
50
+ def __init__(self, names):
51
+ for n in names:
52
+ setattr(self, n, FakePar(n))
53
+
54
+
55
+ class FakeConnector:
56
+ def __init__(self):
57
+ self.connected_to = None
58
+
59
+ def connect(self, other):
60
+ self.connected_to = other
61
+
62
+
63
+ class FakeNode:
64
+ def __init__(self, path, par_names=(), parent_path="/project1", n_in=1, n_out=1):
65
+ self.path = path
66
+ self.name = path.rsplit("/", 1)[-1]
67
+ self.OPType = "fakeTOP"
68
+ self.par = FakeParCollection(par_names)
69
+ self.inputs = []
70
+ self.outputs = []
71
+ self.inputConnectors = [FakeConnector() for _ in range(n_in)]
72
+ self.outputConnectors = [FakeConnector() for _ in range(n_out)]
73
+ self._parent_path = parent_path
74
+
75
+ def pars(self):
76
+ return [v for v in vars(self.par).values() if isinstance(v, FakePar)]
77
+
78
+ def parent(self):
79
+ return types.SimpleNamespace(path=self._parent_path)
80
+
81
+
82
+ class UpdateParametersTests(unittest.TestCase):
83
+ def test_rejects_unknown_param_atomically(self):
84
+ node = FakeNode("/project1/lvl", ["brightness1"])
85
+ with mock.patch.object(api_service, "op", lambda p: node):
86
+ with self.assertRaises(ValueError) as cm:
87
+ api_service.update_parameters("/project1/lvl", {"gain": 0.5, "brightness1": 0.9})
88
+ self.assertIn("gain", str(cm.exception))
89
+ # Atomic: a valid sibling param in the same call is NOT applied either.
90
+ self.assertIsNone(node.par.brightness1.val)
91
+
92
+ def test_applies_known_params(self):
93
+ node = FakeNode("/project1/lvl", ["brightness1", "opacity"])
94
+ with mock.patch.object(api_service, "op", lambda p: node):
95
+ detail = api_service.update_parameters(
96
+ "/project1/lvl", {"brightness1": 0.85, "opacity": 0.5}
97
+ )
98
+ self.assertEqual(node.par.brightness1.val, 0.85)
99
+ self.assertEqual(node.par.opacity.val, 0.5)
100
+ self.assertEqual(detail["path"], "/project1/lvl")
101
+
102
+ def test_missing_node_raises(self):
103
+ with mock.patch.object(api_service, "op", lambda p: None):
104
+ with self.assertRaises(LookupError):
105
+ api_service.update_parameters("/nope", {"x": 1})
106
+
107
+
108
+ class CreateNodeWarningTests(unittest.TestCase):
109
+ def test_failed_params_become_warnings_not_silent(self):
110
+ node = FakeNode("/project1/lvl", ["brightness1"])
111
+ parent = mock.MagicMock(name="parent")
112
+ parent.create.return_value = node
113
+ with mock.patch.object(api_service, "op", lambda p: parent), mock.patch.object(
114
+ api_service, "_resolve_type", lambda t: object
115
+ ):
116
+ ref = api_service.create_node(
117
+ "/project1", "fakeTOP", "lvl", {"gain": 1, "brightness1": 0.5}
118
+ )
119
+ # Node is still created; the bad param surfaces as a warning, not silence.
120
+ self.assertEqual(ref["path"], "/project1/lvl")
121
+ self.assertIn("gain", ref.get("parameter_warnings", []))
122
+ # The valid param still applied.
123
+ self.assertEqual(node.par.brightness1.val, 0.5)
124
+
125
+ def test_no_warnings_when_all_params_valid(self):
126
+ node = FakeNode("/project1/lvl", ["brightness1"])
127
+ parent = mock.MagicMock(name="parent")
128
+ parent.create.return_value = node
129
+ with mock.patch.object(api_service, "op", lambda p: parent), mock.patch.object(
130
+ api_service, "_resolve_type", lambda t: object
131
+ ):
132
+ ref = api_service.create_node("/project1", "fakeTOP", "lvl", {"brightness1": 0.5})
133
+ self.assertNotIn("parameter_warnings", ref)
134
+
135
+
136
+ class ConnectGuardTests(unittest.TestCase):
137
+ def _patch_op(self, nodes):
138
+ return mock.patch.object(batch_service, "op", lambda p: nodes.get(p))
139
+
140
+ def test_rejects_cross_container(self):
141
+ src = FakeNode("/project1/a/x", parent_path="/project1/a")
142
+ dst = FakeNode("/project1/b/y", parent_path="/project1/b")
143
+ with self._patch_op({"/project1/a/x": src, "/project1/b/y": dst}):
144
+ with self.assertRaises(ValueError) as cm:
145
+ batch_service.connect("/project1/a/x", "/project1/b/y")
146
+ self.assertIn("across containers", str(cm.exception))
147
+ # No phantom wire was made.
148
+ self.assertIsNone(dst.inputConnectors[0].connected_to)
149
+
150
+ def test_same_parent_wires(self):
151
+ src = FakeNode("/project1/x", parent_path="/project1")
152
+ dst = FakeNode("/project1/y", parent_path="/project1")
153
+ with self._patch_op({"/project1/x": src, "/project1/y": dst}):
154
+ batch_service.connect("/project1/x", "/project1/y")
155
+ self.assertIs(dst.inputConnectors[0].connected_to, src.outputConnectors[0])
156
+
157
+ def test_bad_connector_index_raises(self):
158
+ src = FakeNode("/project1/x", parent_path="/project1")
159
+ dst = FakeNode("/project1/y", parent_path="/project1", n_in=1)
160
+ with self._patch_op({"/project1/x": src, "/project1/y": dst}):
161
+ with self.assertRaises(IndexError):
162
+ batch_service.connect("/project1/x", "/project1/y", target_input=3)
163
+
164
+ def test_missing_node_raises(self):
165
+ with self._patch_op({}):
166
+ with self.assertRaises(LookupError):
167
+ batch_service.connect("/a", "/b")
168
+
169
+
170
+ class _PerfNode:
171
+ def __init__(self, path, cook_time=0.0, cook_count=0):
172
+ self.path = path
173
+ self.cookTime = cook_time
174
+ self.cookCount = cook_count
175
+
176
+
177
+ class _PerfRoot:
178
+ """Returns direct children at depth=1, all descendants otherwise — like a TD COMP."""
179
+
180
+ def __init__(self, direct, nested):
181
+ self._direct = direct
182
+ self._nested = nested
183
+
184
+ def findChildren(self, depth=None):
185
+ return self._direct if depth == 1 else self._nested
186
+
187
+
188
+ class PerformanceRecursiveTests(unittest.TestCase):
189
+ def _run(self, recursive):
190
+ direct = [_PerfNode("/p/a", 1.0)]
191
+ nested = [_PerfNode("/p/a", 1.0), _PerfNode("/p/sys/inner", 2.0)]
192
+ root = _PerfRoot(direct, nested)
193
+ with mock.patch.object(analysis_service, "op", lambda path: root):
194
+ return analysis_service.performance("/p", recursive=recursive)
195
+
196
+ def test_shallow_measures_only_direct_children(self):
197
+ result = self._run(recursive=False)
198
+ self.assertEqual([n["path"] for n in result["nodes"]], ["/p/a"])
199
+ self.assertEqual(result["total_cook_time_ms"], 1.0)
200
+
201
+ def test_recursive_measures_nested_nodes_too(self):
202
+ result = self._run(recursive=True)
203
+ self.assertEqual(sorted(n["path"] for n in result["nodes"]), ["/p/a", "/p/sys/inner"])
204
+ # Nested node's cook time is now counted in the total.
205
+ self.assertEqual(result["total_cook_time_ms"], 3.0)
206
+
207
+
208
+ if __name__ == "__main__":
209
+ unittest.main()