@dpantani/tdmcp 0.4.0 → 0.6.1
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/README.md +15 -10
- package/dist/cli/agent.d.ts +119 -1
- package/dist/cli/agent.js +14188 -1962
- package/dist/cli/agent.js.map +1 -1
- package/dist/index.js +30182 -14832
- package/dist/index.js.map +1 -1
- package/dist/recipes/animus_rings_visualizer.json +37 -0
- package/dist/recipes/body_tracking_reactive.json +127 -0
- package/dist/recipes/mediapipe_body_dots.json +86 -0
- package/dist/recipes/pose_skeleton_mediapipe.json +67 -0
- package/package.json +36 -6
- package/recipes/animus_rings_visualizer.json +37 -0
- package/recipes/body_tracking_reactive.json +127 -0
- package/recipes/mediapipe_body_dots.json +86 -0
- package/recipes/pose_skeleton_mediapipe.json +67 -0
- package/safeskill.manifest.json +37 -0
- package/scripts/fetch-shader-park-td.mjs +79 -0
- package/scripts/tdmcp-lops.mjs +52 -0
- package/td/README.md +1 -1
- package/td/bootstrap.py +41 -9
- package/td/modules/mcp/controllers/api_controller.py +87 -3
- package/td/modules/mcp/install.py +49 -1
- package/td/modules/mcp/services/api_service.py +82 -0
- package/td/modules/mcp/services/connect_service.py +213 -0
- package/td/modules/mcp/services/log_service.py +136 -0
- package/td/modules/mcp/services/param_text_service.py +218 -0
- package/td/modules/utils/version.py +1 -1
- package/td/tests/test_api_controller.py +0 -307
- package/td/tests/test_services.py +0 -209
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "pose_skeleton_mediapipe",
|
|
3
|
+
"name": "Pose Skeleton (MediaPipe)",
|
|
4
|
+
"description": "A live stick-figure skeleton drawn from the 33 MediaPipe body landmarks (the classic body-tracking look). Point the 'posein' Select CHOP at the pose-landmarks CHOP from the free torinmb/mediapipe-touchdesigner plugin. Webcam only — no Kinect or extra hardware. Or use create_pose_skeleton for a self-contained synthetic preview.",
|
|
5
|
+
"tags": ["mediapipe", "pose", "skeleton", "body-tracking", "camera"],
|
|
6
|
+
"difficulty": "advanced",
|
|
7
|
+
"td_version_min": "2022",
|
|
8
|
+
"nodes": [
|
|
9
|
+
{
|
|
10
|
+
"name": "posein",
|
|
11
|
+
"type": "selectCHOP",
|
|
12
|
+
"comment": "Set 'chops' to the MediaPipe plugin's pose-landmarks CHOP (33 samples, tx/ty/tz)"
|
|
13
|
+
},
|
|
14
|
+
{ "name": "geo", "type": "geometryCOMP" },
|
|
15
|
+
{
|
|
16
|
+
"name": "skeleton",
|
|
17
|
+
"type": "scriptSOP",
|
|
18
|
+
"parent": "geo",
|
|
19
|
+
"render": true,
|
|
20
|
+
"comment": "onCook builds a point per landmark + a polyline per bone"
|
|
21
|
+
},
|
|
22
|
+
{ "name": "skel_cb", "type": "textDAT", "parent": "geo", "comment": "Script SOP callbacks" },
|
|
23
|
+
{
|
|
24
|
+
"name": "wire",
|
|
25
|
+
"type": "lineMAT",
|
|
26
|
+
"parameters": {
|
|
27
|
+
"linenearcolorr": 0.2,
|
|
28
|
+
"linenearcolorg": 1.0,
|
|
29
|
+
"linenearcolorb": 0.9,
|
|
30
|
+
"widthnear": 3
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{ "name": "cam", "type": "cameraCOMP", "parameters": { "tz": 4.6 } },
|
|
34
|
+
{
|
|
35
|
+
"name": "render",
|
|
36
|
+
"type": "renderTOP",
|
|
37
|
+
"parameters": {
|
|
38
|
+
"outputresolution": "custom",
|
|
39
|
+
"resolutionw": 1280,
|
|
40
|
+
"resolutionh": 720,
|
|
41
|
+
"antialias": "3"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{ "name": "out1", "type": "nullTOP" }
|
|
45
|
+
],
|
|
46
|
+
"connections": [{ "from": "render", "to": "out1" }],
|
|
47
|
+
"parameters": [
|
|
48
|
+
{ "name": "callbacks", "node": "skeleton", "param": "callbacks", "value": "skel_cb" },
|
|
49
|
+
{ "name": "material", "node": "geo", "param": "material", "value": "wire" },
|
|
50
|
+
{ "name": "geometry", "node": "render", "param": "geometry", "value": "geo" },
|
|
51
|
+
{ "name": "camera", "node": "render", "param": "camera", "value": "cam" }
|
|
52
|
+
],
|
|
53
|
+
"python_code": {
|
|
54
|
+
"skel_cb": "BONES = [[0,1],[1,2],[2,3],[3,7],[0,4],[4,5],[5,6],[6,8],[9,10],[11,12],[11,13],[13,15],[15,17],[15,19],[15,21],[17,19],[12,14],[14,16],[16,18],[16,20],[16,22],[18,20],[11,23],[12,24],[23,24],[23,25],[25,27],[27,29],[27,31],[29,31],[24,26],[26,28],[28,30],[28,32],[30,32]]\n\ndef onCook(scriptOp):\n scriptOp.clear()\n pose = op('../posein')\n if pose is None or pose.numChans < 3 or pose.numSamples < 1:\n return\n tx = pose['tx']; ty = pose['ty']; tz = pose['tz']\n if tx is None or ty is None or tz is None:\n return\n n = pose.numSamples\n pts = []\n for i in range(n):\n p = scriptOp.appendPoint()\n p.x = float(tx[i]); p.y = float(ty[i]); p.z = float(tz[i])\n pts.append(p)\n for a, b in BONES:\n if a < n and b < n:\n poly = scriptOp.appendPoly(2, closed=False, addPoints=False)\n poly[0].point = pts[a]\n poly[1].point = pts[b]\n return\n"
|
|
55
|
+
},
|
|
56
|
+
"controls": [
|
|
57
|
+
{
|
|
58
|
+
"name": "LineWidth",
|
|
59
|
+
"type": "float",
|
|
60
|
+
"min": 0,
|
|
61
|
+
"max": 20,
|
|
62
|
+
"default": 3,
|
|
63
|
+
"bind_to": ["wire.widthnear"]
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
"preview_description": "A glowing cyan skeleton mirroring the performer's body in real time against black."
|
|
67
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dpantani/tdmcp",
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"summary": "TouchDesigner MCP server for local creative-control workflows.",
|
|
5
|
+
"expectedCapabilities": {
|
|
6
|
+
"filesystem": {
|
|
7
|
+
"read": [
|
|
8
|
+
"project recipes, docs, package manifests and optional user-selected vault/package folders",
|
|
9
|
+
"tdmcp config files from the current project or user config directory"
|
|
10
|
+
],
|
|
11
|
+
"write": [
|
|
12
|
+
"generated bridge files under the user's selected install directory",
|
|
13
|
+
"optional user-selected vault notes and exported media"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"network": {
|
|
17
|
+
"loopback": [
|
|
18
|
+
"TouchDesigner bridge on the configured host and port",
|
|
19
|
+
"optional local LLM endpoint for tdmcp chat"
|
|
20
|
+
],
|
|
21
|
+
"remote": [
|
|
22
|
+
"GitHub release/package downloads when the user installs optional library packages",
|
|
23
|
+
"OpenAI-compatible LLM endpoint only when the user configures a remote base URL"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"process": [
|
|
27
|
+
"opens the local chat UI in a browser",
|
|
28
|
+
"starts local Ollama only when auto-start is enabled and the endpoint is local",
|
|
29
|
+
"uses the platform zip utility when extracting user-requested package archives"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"securityNotes": [
|
|
33
|
+
"Secrets are loaded from explicit environment/config fields and redacted before diagnostic printing.",
|
|
34
|
+
"Archive extraction validates paths and rejects traversal or symlink entries before unpacking.",
|
|
35
|
+
"Raw Python tools can be disabled with TDMCP_RAW_PYTHON=off or hidden with TDMCP_TOOL_PROFILE=safe."
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createWriteStream, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { finished } from "node:stream/promises";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_URL =
|
|
8
|
+
"https://github.com/shader-park/shader-park-touchdesigner/releases/latest/download/Shader_Park_TD.tox";
|
|
9
|
+
const DEFAULT_OUT = "vendor/shader-park/Shader_Park_TD.tox";
|
|
10
|
+
|
|
11
|
+
function usage() {
|
|
12
|
+
return [
|
|
13
|
+
"Download the official Shader Park TouchDesigner .tox plugin.",
|
|
14
|
+
"",
|
|
15
|
+
"Usage:",
|
|
16
|
+
" npm run shader-park:tox",
|
|
17
|
+
" node scripts/fetch-shader-park-td.mjs --out vendor/shader-park/Shader_Park_TD.tox",
|
|
18
|
+
"",
|
|
19
|
+
"Options:",
|
|
20
|
+
" --out <path> Destination path. Defaults to vendor/shader-park/Shader_Park_TD.tox",
|
|
21
|
+
" --url <url> Override the release asset URL.",
|
|
22
|
+
" -h, --help Show this help.",
|
|
23
|
+
].join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const args = { out: DEFAULT_OUT, url: DEFAULT_URL, help: false };
|
|
28
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
29
|
+
const arg = argv[i];
|
|
30
|
+
if (arg === "-h" || arg === "--help") {
|
|
31
|
+
args.help = true;
|
|
32
|
+
} else if (arg === "--out") {
|
|
33
|
+
const value = argv[i + 1];
|
|
34
|
+
if (!value) throw new Error("--out requires a path");
|
|
35
|
+
args.out = value;
|
|
36
|
+
i += 1;
|
|
37
|
+
} else if (arg === "--url") {
|
|
38
|
+
const value = argv[i + 1];
|
|
39
|
+
if (!value) throw new Error("--url requires a URL");
|
|
40
|
+
args.url = value;
|
|
41
|
+
i += 1;
|
|
42
|
+
} else {
|
|
43
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return args;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function download(url, outPath) {
|
|
50
|
+
const response = await fetch(url, {
|
|
51
|
+
headers: { "user-agent": "tdmcp-fetch-shader-park-td" },
|
|
52
|
+
redirect: "follow",
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok || !response.body) {
|
|
55
|
+
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
59
|
+
const file = createWriteStream(outPath);
|
|
60
|
+
await finished(Readable.fromWeb(response.body).pipe(file));
|
|
61
|
+
return Number(response.headers.get("content-length") ?? 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const args = parseArgs(process.argv.slice(2));
|
|
66
|
+
if (args.help) {
|
|
67
|
+
console.log(usage());
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
const outPath = resolve(args.out);
|
|
71
|
+
const bytes = await download(args.url, outPath);
|
|
72
|
+
const size = bytes > 0 ? ` (${bytes} bytes)` : "";
|
|
73
|
+
console.log(`Downloaded Shader_Park_TD.tox to ${outPath}${size}`);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
76
|
+
console.error("");
|
|
77
|
+
console.error(usage());
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launcher for dotsimulate's LOPs "MCP Client": injects hardening env, then
|
|
3
|
+
// execs the tdmcp stdio server. LOPs' servers_config.json has no documented
|
|
4
|
+
// `env` field, so point its `command` at this file instead.
|
|
5
|
+
//
|
|
6
|
+
// command: "node"
|
|
7
|
+
// args: ["/abs/path/to/tdmcp/scripts/tdmcp-lops.mjs"]
|
|
8
|
+
//
|
|
9
|
+
// Sets TDMCP_RAW_PYTHON=off and TDMCP_TOOL_PROFILE=safe so an autonomous in-TD
|
|
10
|
+
// agent gets the curated, non-destructive tool surface. These are FORCED
|
|
11
|
+
// (override anything inherited from the parent) — hardening must win.
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { dirname, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
+
const entry = resolve(root, "dist", "index.js");
|
|
19
|
+
|
|
20
|
+
if (!existsSync(entry)) {
|
|
21
|
+
// stderr ONLY — stdout is the MCP stdio channel and must stay clean.
|
|
22
|
+
process.stderr.write(
|
|
23
|
+
`[tdmcp-lops] ${entry} not found. Run \`npm run build\` in ${root} first.\n`,
|
|
24
|
+
);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const env = {
|
|
29
|
+
...process.env,
|
|
30
|
+
TDMCP_RAW_PYTHON: "off",
|
|
31
|
+
TDMCP_TOOL_PROFILE: "safe",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const child = spawn(process.execPath, [entry, ...process.argv.slice(2)], {
|
|
35
|
+
stdio: "inherit", // pipe stdin/stdout/stderr straight through (MCP handshake)
|
|
36
|
+
env,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Forward termination so the spawned server's lifecycle matches this wrapper's.
|
|
40
|
+
// MCP clients that stop the configured command by killing this process must not
|
|
41
|
+
// leave an orphaned tdmcp server holding stdio + bridge connections.
|
|
42
|
+
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
43
|
+
process.on(signal, () => {
|
|
44
|
+
child.kill(signal);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
child.on("error", (err) => {
|
|
49
|
+
process.stderr.write(`[tdmcp-lops] failed to start tdmcp: ${err.message}\n`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
});
|
|
52
|
+
child.on("close", (code) => process.exit(code ?? 1));
|
package/td/README.md
CHANGED
|
@@ -19,7 +19,7 @@ idempotent, and can be undone with `from mcp import install; install.uninstall()
|
|
|
19
19
|
(`Dialogs → Textport and DATs`):
|
|
20
20
|
|
|
21
21
|
```python
|
|
22
|
-
import urllib.request; exec(urllib.request.urlopen("https://
|
|
22
|
+
import urllib.request; exec(urllib.request.urlopen("https://github.com/Pantani/tdmcp/raw/main/td/bootstrap.py").read().decode())
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
It downloads the bridge to `~/tdmcp-bridge/modules` and starts it on port 9980.
|
package/td/bootstrap.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Paste this single line into the Textport (Dialogs -> Textport and DATs) and the
|
|
4
4
|
bridge installs itself and starts:
|
|
5
5
|
|
|
6
|
-
import urllib.request; exec(urllib.request.urlopen("https://
|
|
6
|
+
import urllib.request; exec(urllib.request.urlopen("https://github.com/Pantani/tdmcp/raw/main/td/bootstrap.py").read().decode())
|
|
7
7
|
|
|
8
8
|
It downloads just the bridge modules to ~/tdmcp-bridge/modules, puts them on
|
|
9
9
|
sys.path for this session, and runs install.run() -> a tdmcp_bridge on port 9980.
|
|
@@ -15,6 +15,7 @@ private, point REPO_ZIP at a public release asset, or use `install-bridge`
|
|
|
15
15
|
|
|
16
16
|
import io
|
|
17
17
|
import os
|
|
18
|
+
import stat
|
|
18
19
|
import sys
|
|
19
20
|
import zipfile
|
|
20
21
|
import urllib.request
|
|
@@ -22,6 +23,37 @@ import urllib.request
|
|
|
22
23
|
REPO_ZIP = "https://github.com/Pantani/tdmcp/archive/refs/heads/main.zip"
|
|
23
24
|
DEST = os.path.expanduser("~/tdmcp-bridge")
|
|
24
25
|
_MARKER = "/td/modules/"
|
|
26
|
+
_SKIP_RUN_ENV = "TDMCP_BOOTSTRAP_SKIP_RUN"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_symlink(info):
|
|
30
|
+
return stat.S_ISLNK((info.external_attr >> 16) & 0o170000)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _safe_module_path(name, modules_dir):
|
|
34
|
+
idx = name.find(_MARKER)
|
|
35
|
+
if idx == -1:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
rel = name[idx + len(_MARKER):].replace("\\", "/")
|
|
39
|
+
if not rel or rel.endswith("/"):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
parts = rel.split("/")
|
|
43
|
+
if (
|
|
44
|
+
rel.startswith("/")
|
|
45
|
+
or rel.startswith("\\")
|
|
46
|
+
or (len(parts[0]) >= 2 and parts[0][1] == ":")
|
|
47
|
+
or any(part in ("", ".", "..") for part in parts)
|
|
48
|
+
):
|
|
49
|
+
raise RuntimeError("[tdmcp] Refusing unsafe archive entry: %s" % name)
|
|
50
|
+
|
|
51
|
+
root = os.path.realpath(modules_dir)
|
|
52
|
+
target = os.path.realpath(os.path.join(modules_dir, *parts))
|
|
53
|
+
if target != root and not target.startswith(root + os.sep):
|
|
54
|
+
raise RuntimeError("[tdmcp] Refusing archive entry outside modules: %s" % name)
|
|
55
|
+
|
|
56
|
+
return target
|
|
25
57
|
|
|
26
58
|
|
|
27
59
|
def fetch_modules(repo_zip=REPO_ZIP, dest=DEST):
|
|
@@ -39,16 +71,15 @@ def fetch_modules(repo_zip=REPO_ZIP, dest=DEST):
|
|
|
39
71
|
zf = zipfile.ZipFile(io.BytesIO(data))
|
|
40
72
|
os.makedirs(modules_dir, exist_ok=True)
|
|
41
73
|
extracted = 0
|
|
42
|
-
for
|
|
74
|
+
for info in zf.infolist():
|
|
75
|
+
name = info.filename
|
|
43
76
|
if name.endswith("/"):
|
|
44
77
|
continue
|
|
45
|
-
|
|
46
|
-
if
|
|
47
|
-
continue
|
|
48
|
-
rel = name[idx + len(_MARKER):]
|
|
49
|
-
if not rel:
|
|
78
|
+
target = _safe_module_path(name, modules_dir)
|
|
79
|
+
if target is None:
|
|
50
80
|
continue
|
|
51
|
-
|
|
81
|
+
if _is_symlink(info):
|
|
82
|
+
raise RuntimeError("[tdmcp] Refusing symlink archive entry: %s" % name)
|
|
52
83
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
53
84
|
with zf.open(name) as src, open(target, "wb") as out:
|
|
54
85
|
out.write(src.read())
|
|
@@ -70,4 +101,5 @@ def run(repo_zip=REPO_ZIP, dest=DEST, port=9980):
|
|
|
70
101
|
|
|
71
102
|
|
|
72
103
|
# Running via exec(urlopen(...).read()) or as a script kicks off the install.
|
|
73
|
-
|
|
104
|
+
if os.environ.get(_SKIP_RUN_ENV) != "1":
|
|
105
|
+
run()
|
|
@@ -13,7 +13,15 @@ import os
|
|
|
13
13
|
from urllib.parse import parse_qs, unquote, urlparse
|
|
14
14
|
|
|
15
15
|
from mcp import events
|
|
16
|
-
from mcp.services import
|
|
16
|
+
from mcp.services import (
|
|
17
|
+
analysis_service,
|
|
18
|
+
api_service,
|
|
19
|
+
batch_service,
|
|
20
|
+
connect_service,
|
|
21
|
+
log_service,
|
|
22
|
+
param_text_service,
|
|
23
|
+
preview_service,
|
|
24
|
+
)
|
|
17
25
|
|
|
18
26
|
|
|
19
27
|
class _Unauthorized(PermissionError):
|
|
@@ -158,7 +166,23 @@ def _require(body, *keys):
|
|
|
158
166
|
raise ValueError("Missing required field(s): %s." % ", ".join(missing))
|
|
159
167
|
|
|
160
168
|
|
|
161
|
-
def
|
|
169
|
+
def _bridge_error_log_path(webserver):
|
|
170
|
+
"""Resolve the installed Error DAT's path from the webserver that serves this
|
|
171
|
+
request. The API is served by the webserver DAT INSIDE the bridge container, so
|
|
172
|
+
the Error DAT is ``webserver.parent().op('error_log')`` regardless of a custom
|
|
173
|
+
``parent_path``/``container``. Returns None when it can't be resolved (e.g. the
|
|
174
|
+
webserver isn't threaded, as in tests) so the caller falls back to the default."""
|
|
175
|
+
if webserver is None:
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
bridge = webserver.parent()
|
|
179
|
+
ed = bridge.op("error_log") if bridge is not None else None
|
|
180
|
+
return ed.path if ed is not None else None
|
|
181
|
+
except Exception: # noqa: BLE001
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _route(method, path, query, body, webserver=None):
|
|
162
186
|
parts = [p for p in path.split("/") if p]
|
|
163
187
|
if not parts or parts[0] != "api":
|
|
164
188
|
raise ValueError("Not found: %s" % path)
|
|
@@ -184,6 +208,36 @@ def _route(method, path, query, body):
|
|
|
184
208
|
if rest == ["batch"] and method == "POST":
|
|
185
209
|
return batch_service.run(body.get("operations", []))
|
|
186
210
|
|
|
211
|
+
# Structured wiring + logs endpoints — NO exec gate (they must survive
|
|
212
|
+
# TDMCP_BRIDGE_ALLOW_EXEC=0). Top-level paths, so no collision with nodes/network.
|
|
213
|
+
if rest == ["connect"] and method == "POST":
|
|
214
|
+
_require(body, "source_path", "target_path")
|
|
215
|
+
return connect_service.connect(
|
|
216
|
+
body["source_path"],
|
|
217
|
+
body["target_path"],
|
|
218
|
+
int(body.get("source_output", 0)),
|
|
219
|
+
int(body.get("target_input", 0)),
|
|
220
|
+
)
|
|
221
|
+
if rest == ["disconnect"] and method == "POST":
|
|
222
|
+
_require(body, "to_path")
|
|
223
|
+
return connect_service.disconnect(
|
|
224
|
+
body["to_path"], body.get("from_path"), body.get("to_input")
|
|
225
|
+
)
|
|
226
|
+
if rest == ["logs"] and method == "GET":
|
|
227
|
+
# Resolve the Error DAT relative to the webserver's own container so a custom
|
|
228
|
+
# install (parent_path/container) works; fall back to get_logs' default when
|
|
229
|
+
# the webserver isn't threaded (e.g. tests).
|
|
230
|
+
log_kwargs = {}
|
|
231
|
+
error_dat = _bridge_error_log_path(webserver)
|
|
232
|
+
if error_dat:
|
|
233
|
+
log_kwargs["error_dat_path"] = error_dat
|
|
234
|
+
return log_service.get_logs(
|
|
235
|
+
_qs(query, "severity", "all"),
|
|
236
|
+
int(_qs(query, "max_lines", 200)),
|
|
237
|
+
_qs(query, "scope") or None,
|
|
238
|
+
**log_kwargs,
|
|
239
|
+
)
|
|
240
|
+
|
|
187
241
|
if rest[0] == "nodes" and len(rest) >= 2:
|
|
188
242
|
if rest[-1] == "method" and method == "POST":
|
|
189
243
|
if not _exec_allowed():
|
|
@@ -199,6 +253,36 @@ def _route(method, path, query, body):
|
|
|
199
253
|
)
|
|
200
254
|
if rest[-1] == "errors" and method == "GET":
|
|
201
255
|
return api_service.get_node_errors(_node_path(rest[1:-1]), recursive=False)
|
|
256
|
+
# Param-mode + DAT-text suffixes — MORE SPECIFIC than the generic node CRUD
|
|
257
|
+
# below, so they MUST be matched first (else `…/text` GET is swallowed by
|
|
258
|
+
# get_node). No exec gate — structured endpoints survive ALLOW_EXEC=0.
|
|
259
|
+
if rest[-1] == "params" and method == "GET" and _qs(query, "modes") == "true":
|
|
260
|
+
return param_text_service.read_param_modes(
|
|
261
|
+
_node_path(rest[1:-1]),
|
|
262
|
+
(_qs(query, "keys").split(",") if _qs(query, "keys") else None),
|
|
263
|
+
_qs(query, "non_default_only") == "true",
|
|
264
|
+
)
|
|
265
|
+
if len(rest) >= 4 and rest[-1] == "mode" and rest[-3] == "params" and method == "PATCH":
|
|
266
|
+
# /api/nodes/<path…>/params/<param>/mode
|
|
267
|
+
return param_text_service.set_param_mode(
|
|
268
|
+
_node_path(rest[1:-3]),
|
|
269
|
+
unquote(rest[-2]),
|
|
270
|
+
body.get("mode", "expression"),
|
|
271
|
+
body.get("expr"),
|
|
272
|
+
body.get("value"),
|
|
273
|
+
)
|
|
274
|
+
if rest[-1] == "text" and method == "GET":
|
|
275
|
+
# Disambiguate a node literally named "text": the /text suffix only means
|
|
276
|
+
# "read this DAT's text" when the PARENT is actually a DAT. Otherwise the
|
|
277
|
+
# WebServer DAT decoded the path's slashes and "text" is the node's own
|
|
278
|
+
# name, so return that node's detail instead of the parent's DAT text.
|
|
279
|
+
_txt_parent = _node_path(rest[1:-1])
|
|
280
|
+
if param_text_service.is_dat(_txt_parent):
|
|
281
|
+
return param_text_service.get_dat_text(_txt_parent)
|
|
282
|
+
return api_service.get_node(_node_path(rest[1:]))
|
|
283
|
+
if rest[-1] == "text" and method == "PUT":
|
|
284
|
+
_require(body, "text")
|
|
285
|
+
return param_text_service.put_dat_text(_node_path(rest[1:-1]), body["text"])
|
|
202
286
|
node_path = _node_path(rest[1:])
|
|
203
287
|
if method == "GET":
|
|
204
288
|
return api_service.get_node(node_path)
|
|
@@ -294,7 +378,7 @@ def handle(request, response, webserver=None):
|
|
|
294
378
|
parsed = urlparse(request.get("uri", "/"))
|
|
295
379
|
query = _merge_query(request, parse_qs(parsed.query))
|
|
296
380
|
body = _parse_body(request)
|
|
297
|
-
data = _route(method, parsed.path, query, body)
|
|
381
|
+
data = _route(method, parsed.path, query, body, webserver)
|
|
298
382
|
_emit_event(webserver, method, parsed.path, data)
|
|
299
383
|
return _send(response, 200, {"ok": True, "data": data})
|
|
300
384
|
except PermissionError as exc:
|
|
@@ -70,6 +70,27 @@ def _event_hooks_source(modules_dir=None):
|
|
|
70
70
|
" emitted += 1\n"
|
|
71
71
|
" if emitted >= 10:\n"
|
|
72
72
|
" break\n"
|
|
73
|
+
" # Edge-triggered cook.error / error.cleared from the bridge Error DAT.\n"
|
|
74
|
+
" # Broadcast on a row-count delta OR a newest-row identity change. At\n"
|
|
75
|
+
" # the maxlines cap, clamp replaces old rows so numRows stops growing —\n"
|
|
76
|
+
" # tracking the newest (absframe, message) too keeps cook.error firing\n"
|
|
77
|
+
" # for fresh errors after the buffer fills (still <=1 per frame).\n"
|
|
78
|
+
" _ed = me.parent().op('error_log')\n"
|
|
79
|
+
" if _ed is not None and _ed.numRows > 0:\n"
|
|
80
|
+
" _rows = _ed.numRows - 1 # data rows, minus header\n"
|
|
81
|
+
" _prev = getattr(me, '_tdmcp_err_rows', 0)\n"
|
|
82
|
+
" _prev_new = getattr(me, '_tdmcp_err_newest', None)\n"
|
|
83
|
+
" _new = (str(_ed[_rows, 2]), str(_ed[_rows, 1])) if _rows > 0 else None\n"
|
|
84
|
+
" if _rows != _prev or _new != _prev_new:\n"
|
|
85
|
+
" me._tdmcp_err_rows = _rows\n"
|
|
86
|
+
" me._tdmcp_err_newest = _new\n"
|
|
87
|
+
" if _rows == 0:\n"
|
|
88
|
+
" _broadcast('error.cleared', {'count': 0})\n"
|
|
89
|
+
" else:\n"
|
|
90
|
+
" _broadcast('cook.error', {\n"
|
|
91
|
+
" 'source': str(_ed[_rows, 0]), 'message': str(_ed[_rows, 1]),\n"
|
|
92
|
+
" 'severity': str(_ed[_rows, 4]), 'type': str(_ed[_rows, 5]), 'count': _rows,\n"
|
|
93
|
+
" })\n"
|
|
73
94
|
" except Exception:\n"
|
|
74
95
|
" pass\n\n"
|
|
75
96
|
"def onProjectPostSave():\n"
|
|
@@ -82,7 +103,14 @@ def _event_hooks_source(modules_dir=None):
|
|
|
82
103
|
)
|
|
83
104
|
|
|
84
105
|
|
|
85
|
-
def run(
|
|
106
|
+
def run(
|
|
107
|
+
port=9980,
|
|
108
|
+
parent_path="/project1",
|
|
109
|
+
container="tdmcp_bridge",
|
|
110
|
+
modules_dir=None,
|
|
111
|
+
export_tox=None,
|
|
112
|
+
error_scope=None,
|
|
113
|
+
):
|
|
86
114
|
import td # TouchDesigner globals are only available via the td module here
|
|
87
115
|
|
|
88
116
|
if modules_dir:
|
|
@@ -109,6 +137,26 @@ def run(port=9980, parent_path="/project1", container="tdmcp_bridge", modules_di
|
|
|
109
137
|
hooks.par.frameend = True
|
|
110
138
|
hooks.par.projectpostsave = True
|
|
111
139
|
|
|
140
|
+
# Error DAT: structured cook-error/warning capture for GET /api/logs + the
|
|
141
|
+
# edge-triggered cook.error / error.cleared events. Idempotent like the rest.
|
|
142
|
+
# The path /<container>/error_log must stay in sync with log_service.get_logs'
|
|
143
|
+
# error_dat_path default and getBridgeLogs' fallback assumption.
|
|
144
|
+
err = comp.op("error_log") or comp.create(td.errorDAT, "error_log")
|
|
145
|
+
err.par.active = True
|
|
146
|
+
err.par.maxlines = 200 # default 10 is too small for a show
|
|
147
|
+
err.par.clamp = True # keep newest within maxlines
|
|
148
|
+
err.par.severity = "*" # capture errors AND warnings
|
|
149
|
+
err.par.source = "*"
|
|
150
|
+
try:
|
|
151
|
+
# Watch the artist's whole network by default (not just the bridge container).
|
|
152
|
+
# Set the VALUE directly (a constant op path) — assigning .expr alone would
|
|
153
|
+
# not switch the par into Expression mode, so it would keep its default/
|
|
154
|
+
# constant fromop and never watch parent_path/error_scope.
|
|
155
|
+
scope = error_scope or parent_path
|
|
156
|
+
err.par.fromop.val = scope
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
112
160
|
if export_tox:
|
|
113
161
|
comp.save(export_tox)
|
|
114
162
|
|
|
@@ -98,6 +98,58 @@ def get_nodes(parent_path=None):
|
|
|
98
98
|
return {"nodes": [node_ref(c) for c in children]}
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
def _flags(node):
|
|
102
|
+
out = {}
|
|
103
|
+
for attr in ("bypass", "render", "display", "lock", "allowCooking", "cloneImmune"):
|
|
104
|
+
try:
|
|
105
|
+
v = getattr(node, attr)
|
|
106
|
+
if isinstance(v, bool):
|
|
107
|
+
out[attr] = v
|
|
108
|
+
except Exception: # noqa: BLE001
|
|
109
|
+
pass
|
|
110
|
+
# clone is COMP-only and lives on .par.clone (path to master), NOT op.clone.
|
|
111
|
+
try:
|
|
112
|
+
if hasattr(node, "isClone"):
|
|
113
|
+
out["is_clone"] = bool(node.isClone)
|
|
114
|
+
except Exception: # noqa: BLE001
|
|
115
|
+
pass
|
|
116
|
+
try:
|
|
117
|
+
cp = getattr(node.par, "clone", None)
|
|
118
|
+
if cp is not None:
|
|
119
|
+
cv = cp.eval()
|
|
120
|
+
out["clone"] = str(cv) if cv else None
|
|
121
|
+
except Exception: # noqa: BLE001
|
|
122
|
+
pass
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _indexed_inputs(node):
|
|
127
|
+
# Faithful, index-aware: iterate inputConnectors (NOT node.inputs, which omits empty
|
|
128
|
+
# slots). Each wire => {in_index, from, out_index}. Multi-input TOPs pack contiguously,
|
|
129
|
+
# so the indices reported are the live/current ones.
|
|
130
|
+
wires = []
|
|
131
|
+
try:
|
|
132
|
+
for ic in node.inputConnectors:
|
|
133
|
+
try:
|
|
134
|
+
in_index = ic.index
|
|
135
|
+
except Exception: # noqa: BLE001
|
|
136
|
+
in_index = None
|
|
137
|
+
try:
|
|
138
|
+
conns = list(ic.connections)
|
|
139
|
+
except Exception: # noqa: BLE001
|
|
140
|
+
conns = []
|
|
141
|
+
for oc in conns:
|
|
142
|
+
try:
|
|
143
|
+
wires.append(
|
|
144
|
+
{"in_index": in_index, "from": oc.owner.path, "out_index": oc.index}
|
|
145
|
+
)
|
|
146
|
+
except Exception: # noqa: BLE001
|
|
147
|
+
pass
|
|
148
|
+
except Exception: # noqa: BLE001
|
|
149
|
+
pass
|
|
150
|
+
return wires
|
|
151
|
+
|
|
152
|
+
|
|
101
153
|
def node_detail(node):
|
|
102
154
|
pars = {}
|
|
103
155
|
try:
|
|
@@ -112,6 +164,36 @@ def node_detail(node):
|
|
|
112
164
|
outputs = [c.path for c in getattr(node, "outputs", []) if c]
|
|
113
165
|
detail = node_ref(node)
|
|
114
166
|
detail.update({"parameters": pars, "inputs": inputs, "outputs": outputs})
|
|
167
|
+
# --- NEW (node_flags_in_detail): flags + index-aware wiring + position/comment/color/tags ---
|
|
168
|
+
detail["flags"] = _flags(node)
|
|
169
|
+
detail["wires_in"] = _indexed_inputs(node)
|
|
170
|
+
# op.errors() returns a STRING (not a list) — wrap it so a multi-line message is
|
|
171
|
+
# ONE entry, never iterated char-by-char. Lets get_td_node_flags' REST path flag
|
|
172
|
+
# "cook error" suspects (and honor only_problems) the same as the exec walk.
|
|
173
|
+
try:
|
|
174
|
+
_err = node.errors(recurse=False)
|
|
175
|
+
detail["errors"] = [str(_err)] if _err else []
|
|
176
|
+
except Exception: # noqa: BLE001
|
|
177
|
+
pass
|
|
178
|
+
try:
|
|
179
|
+
detail["nodeX"] = node.nodeX
|
|
180
|
+
detail["nodeY"] = node.nodeY
|
|
181
|
+
except Exception: # noqa: BLE001
|
|
182
|
+
pass
|
|
183
|
+
try:
|
|
184
|
+
if node.comment:
|
|
185
|
+
detail["comment"] = node.comment
|
|
186
|
+
except Exception: # noqa: BLE001
|
|
187
|
+
pass
|
|
188
|
+
try:
|
|
189
|
+
detail["color"] = list(node.color) # tuple -> JSON list
|
|
190
|
+
except Exception: # noqa: BLE001
|
|
191
|
+
pass
|
|
192
|
+
try:
|
|
193
|
+
if node.tags:
|
|
194
|
+
detail["tags"] = sorted(str(t) for t in node.tags) # set -> sorted list
|
|
195
|
+
except Exception: # noqa: BLE001
|
|
196
|
+
pass
|
|
115
197
|
return detail
|
|
116
198
|
|
|
117
199
|
|