@inneranimalmedia/agentsam-sdk 2.3.0 → 2.4.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/examples/cad/blender-box-with-hole.recipe.json +71 -0
- package/package.json +3 -2
- package/packages/identity/package.json +1 -1
- package/protocol/cad/blender-recipe.schema.json +92 -0
- package/protocol/capabilities/manifest.json +76 -0
- package/services/cad/blender/adapter.py +603 -0
- package/src/cli.js +9 -0
- package/src/commands/cad.js +188 -0
- package/src/lib/auth.js +10 -19
- package/src/lib/cad/blender.js +340 -0
- package/src/lib/cad/index.js +15 -0
- package/src/lib/open-url.js +66 -0
- package/test/blender-cad.test.mjs +146 -0
- package/test/open-url.test.mjs +64 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
blenderBuild,
|
|
5
|
+
blenderExport,
|
|
6
|
+
blenderInspect,
|
|
7
|
+
blenderRenderPreview,
|
|
8
|
+
blenderStatus,
|
|
9
|
+
} from '../lib/cad/index.js';
|
|
10
|
+
import { promptToOpenUrl } from '../lib/open-url.js';
|
|
11
|
+
|
|
12
|
+
const BLENDER_DOWNLOAD_URL = 'https://www.blender.org/download/';
|
|
13
|
+
|
|
14
|
+
function usage() {
|
|
15
|
+
return `AgentSam programmatic CAD
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
agentsam cad blender status [--blender-bin <path>] [--json]
|
|
19
|
+
agentsam cad blender inspect <model.blend> [--timeout <seconds>] [--json]
|
|
20
|
+
agentsam cad blender build <recipe.json> --out <model.blend> [--input <base.blend>] [--json]
|
|
21
|
+
agentsam cad blender render-preview <model.blend> --out <preview.png> [--camera <name>] [--scene <name>] [--width 1024] [--height 1024] [--json]
|
|
22
|
+
agentsam cad blender export <model.blend> --format <glb|stl|obj> --out <artifact> [--objects <a,b>] [--collection <name>] [--json]
|
|
23
|
+
|
|
24
|
+
Shared options:
|
|
25
|
+
--blender-bin <path> Explicit Blender executable. Otherwise AGENTSAM_BLENDER_BIN, PATH, then common install locations are checked.
|
|
26
|
+
--timeout <seconds> Bounded execution time, 1..600 (default 120).
|
|
27
|
+
--cwd <path> Resolve input/output paths from another directory.
|
|
28
|
+
--json Machine-readable output.
|
|
29
|
+
|
|
30
|
+
If Blender is missing, AgentSam explains what is required and can open the official Blender download page.
|
|
31
|
+
The build command consumes a typed recipe; it never evaluates arbitrary Python.`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const opts = { positional: [], json: false, applyModifiers: true };
|
|
36
|
+
const values = new Set([
|
|
37
|
+
'--blender-bin', '--timeout', '--cwd', '--out', '--input', '--camera', '--scene',
|
|
38
|
+
'--width', '--height', '--engine', '--format', '--objects', '--collection',
|
|
39
|
+
]);
|
|
40
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
41
|
+
const arg = argv[i];
|
|
42
|
+
if (arg === '--json') opts.json = true;
|
|
43
|
+
else if (arg === '--no-apply-modifiers') opts.applyModifiers = false;
|
|
44
|
+
else if (arg === '--help' || arg === '-h') opts.help = true;
|
|
45
|
+
else if (values.has(arg)) {
|
|
46
|
+
const value = argv[++i];
|
|
47
|
+
if (value == null || value.startsWith('--')) throw new Error(`${arg} requires a value`);
|
|
48
|
+
const key = arg.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
49
|
+
opts[key] = value;
|
|
50
|
+
} else if (arg.startsWith('-')) throw new Error(`unknown cad option: ${arg}`);
|
|
51
|
+
else opts.positional.push(arg);
|
|
52
|
+
}
|
|
53
|
+
return opts;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function withBlenderInstallGuidance(value) {
|
|
57
|
+
if (value?.available !== false) return value;
|
|
58
|
+
return {
|
|
59
|
+
...value,
|
|
60
|
+
install: {
|
|
61
|
+
required: true,
|
|
62
|
+
app: 'Blender',
|
|
63
|
+
url: BLENDER_DOWNLOAD_URL,
|
|
64
|
+
message: 'Install Blender, then rerun this command. AgentSam will discover standard installs automatically.',
|
|
65
|
+
alternatives: [
|
|
66
|
+
'Pass --blender-bin <path> for a custom Blender executable.',
|
|
67
|
+
'Set AGENTSAM_BLENDER_BIN for a persistent custom executable path.',
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function output(value, json) {
|
|
74
|
+
if (json) {
|
|
75
|
+
console.log(JSON.stringify(value));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (value.capability === 'blender.status') {
|
|
79
|
+
if (value.available) {
|
|
80
|
+
console.log(`Blender available: ${value.version || 'unknown version'}\n${value.binary}`);
|
|
81
|
+
} else {
|
|
82
|
+
console.log(`Blender unavailable${value.error ? `: ${value.error}` : ''}`);
|
|
83
|
+
if (value.install?.message) console.log(value.install.message);
|
|
84
|
+
if (value.install?.url) console.log(`Download: ${value.install.url}`);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
console.log(JSON.stringify(value, null, 2));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function presentMissingBlender(value, json) {
|
|
92
|
+
const guided = withBlenderInstallGuidance(value);
|
|
93
|
+
output(guided, json);
|
|
94
|
+
if (!json) {
|
|
95
|
+
await promptToOpenUrl(BLENDER_DOWNLOAD_URL, {
|
|
96
|
+
heading: 'Blender is required for AgentSam programmatic CAD:',
|
|
97
|
+
prompt: 'Press ENTER to open the official Blender download page.',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return guided;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function required(value, message) {
|
|
104
|
+
if (!value) throw new Error(message);
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function runCad(argv) {
|
|
109
|
+
const engine = argv[0];
|
|
110
|
+
if (!engine || engine === '--help' || engine === '-h') {
|
|
111
|
+
console.log(usage());
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (engine !== 'blender') throw new Error(`unsupported CAD engine: ${engine}. Expected blender.`);
|
|
115
|
+
|
|
116
|
+
const action = argv[1];
|
|
117
|
+
const opts = parseArgs(argv.slice(2));
|
|
118
|
+
if (!action || opts.help) {
|
|
119
|
+
console.log(usage());
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cwd = path.resolve(opts.cwd || process.cwd());
|
|
124
|
+
const shared = {
|
|
125
|
+
blenderBin: opts.blenderBin,
|
|
126
|
+
timeoutSeconds: opts.timeout == null ? undefined : Number(opts.timeout),
|
|
127
|
+
cwd,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
if (action === 'status') {
|
|
131
|
+
const status = withBlenderInstallGuidance(await blenderStatus(shared));
|
|
132
|
+
output(status, opts.json);
|
|
133
|
+
if (!status.available && !opts.json) {
|
|
134
|
+
await promptToOpenUrl(BLENDER_DOWNLOAD_URL, {
|
|
135
|
+
heading: 'Install Blender to enable AgentSam CAD:',
|
|
136
|
+
prompt: 'Press ENTER to open the official Blender download page.',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return status;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const availability = await blenderStatus(shared);
|
|
143
|
+
if (!availability.available) return presentMissingBlender(availability, opts.json);
|
|
144
|
+
|
|
145
|
+
let result;
|
|
146
|
+
if (action === 'inspect') {
|
|
147
|
+
result = await blenderInspect({ ...shared, input: required(opts.positional[0], 'inspect requires <model.blend>') });
|
|
148
|
+
} else if (action === 'build') {
|
|
149
|
+
const recipeFile = path.resolve(cwd, required(opts.positional[0], 'build requires <recipe.json>'));
|
|
150
|
+
if (!fs.existsSync(recipeFile)) throw new Error(`recipe file not found: ${recipeFile}`);
|
|
151
|
+
let recipe;
|
|
152
|
+
try { recipe = JSON.parse(fs.readFileSync(recipeFile, 'utf8')); }
|
|
153
|
+
catch (error) { throw new Error(`invalid recipe JSON: ${error.message}`); }
|
|
154
|
+
result = await blenderBuild({
|
|
155
|
+
...shared,
|
|
156
|
+
input: opts.input,
|
|
157
|
+
output: required(opts.out, 'build requires --out <model.blend>'),
|
|
158
|
+
recipe,
|
|
159
|
+
});
|
|
160
|
+
} else if (action === 'render-preview') {
|
|
161
|
+
result = await blenderRenderPreview({
|
|
162
|
+
...shared,
|
|
163
|
+
input: required(opts.positional[0], 'render-preview requires <model.blend>'),
|
|
164
|
+
output: required(opts.out, 'render-preview requires --out <preview.png>'),
|
|
165
|
+
scene: opts.scene,
|
|
166
|
+
camera: opts.camera,
|
|
167
|
+
width: opts.width == null ? undefined : Number(opts.width),
|
|
168
|
+
height: opts.height == null ? undefined : Number(opts.height),
|
|
169
|
+
engine: opts.engine,
|
|
170
|
+
});
|
|
171
|
+
} else if (action === 'export') {
|
|
172
|
+
result = await blenderExport({
|
|
173
|
+
...shared,
|
|
174
|
+
input: required(opts.positional[0], 'export requires <model.blend>'),
|
|
175
|
+
output: required(opts.out, 'export requires --out <artifact>'),
|
|
176
|
+
format: required(opts.format, 'export requires --format <glb|stl|obj>'),
|
|
177
|
+
scene: opts.scene,
|
|
178
|
+
objects: opts.objects,
|
|
179
|
+
collection: opts.collection,
|
|
180
|
+
applyModifiers: opts.applyModifiers,
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
throw new Error(`unknown Blender CAD action: ${action}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
output(result, opts.json);
|
|
187
|
+
return result;
|
|
188
|
+
}
|
package/src/lib/auth.js
CHANGED
|
@@ -1,28 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Browser OAuth for SDK init — one click IAM login + Cloudflare connect.
|
|
3
3
|
*/
|
|
4
|
-
import http from 'http';
|
|
5
|
-
import { randomBytes } from 'crypto';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
6
|
import { postJson } from './core-client.js';
|
|
7
|
+
import { promptToOpenUrl } from './open-url.js';
|
|
7
8
|
|
|
8
9
|
function randomState() {
|
|
9
10
|
return randomBytes(16).toString('hex');
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
function openBrowser(url) {
|
|
13
|
-
const start =
|
|
14
|
-
process.platform === 'darwin'
|
|
15
|
-
? ['open', url]
|
|
16
|
-
: process.platform === 'win32'
|
|
17
|
-
? ['cmd', '/c', 'start', '', url]
|
|
18
|
-
: ['xdg-open', url];
|
|
19
|
-
import('child_process').then(({ spawn }) => {
|
|
20
|
-
spawn(start[0], start.slice(1), { stdio: 'ignore', detached: true }).unref();
|
|
21
|
-
}).catch(() => {
|
|
22
|
-
console.log(`\n Open in browser:\n ${url}\n`);
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
|
|
26
13
|
/**
|
|
27
14
|
* @returns {Promise<{ access_token: string, user_id: string, workspace_id: string, tenant_id: string }>}
|
|
28
15
|
*/
|
|
@@ -36,6 +23,8 @@ export async function authenticateViaBrowser() {
|
|
|
36
23
|
state,
|
|
37
24
|
});
|
|
38
25
|
|
|
26
|
+
if (!authUrl) throw new Error('IAM auth did not return an authorization URL');
|
|
27
|
+
|
|
39
28
|
const codePromise = new Promise((resolve, reject) => {
|
|
40
29
|
const server = http.createServer((req, res) => {
|
|
41
30
|
try {
|
|
@@ -55,7 +44,7 @@ export async function authenticateViaBrowser() {
|
|
|
55
44
|
return;
|
|
56
45
|
}
|
|
57
46
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
58
|
-
res.end('<html><body style="font-family:system-ui"><h1>Agent Sam</h1><p>You can close this tab.</p></body></html>');
|
|
47
|
+
res.end('<html><body style="font-family:system-ui"><h1>Agent Sam</h1><p>Authentication complete. You can close this tab and return to your terminal.</p></body></html>');
|
|
59
48
|
resolve(code);
|
|
60
49
|
server.close();
|
|
61
50
|
} catch (e) {
|
|
@@ -67,8 +56,10 @@ export async function authenticateViaBrowser() {
|
|
|
67
56
|
server.listen(port, '127.0.0.1');
|
|
68
57
|
});
|
|
69
58
|
|
|
70
|
-
|
|
71
|
-
|
|
59
|
+
await promptToOpenUrl(authUrl, {
|
|
60
|
+
heading: 'Authenticate your InnerAnimalMedia account at:',
|
|
61
|
+
prompt: 'Press ENTER to open InnerAnimalMedia sign-in in your browser.',
|
|
62
|
+
});
|
|
72
63
|
|
|
73
64
|
const code = await codePromise;
|
|
74
65
|
const session = await postJson('/api/sdk/auth/exchange', { code, state });
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { runProcess } from '../../security/process.js';
|
|
7
|
+
|
|
8
|
+
const sdkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
9
|
+
export const BLENDER_ADAPTER_PATH = path.join(sdkRoot, 'services/cad/blender/adapter.py');
|
|
10
|
+
export const BLENDER_RESULT_PREFIX = 'AGENTSAM_RESULT=';
|
|
11
|
+
export const BLENDER_EXPORT_FORMATS = Object.freeze(['glb', 'stl', 'obj']);
|
|
12
|
+
export const BLENDER_RECIPE_OPS = Object.freeze([
|
|
13
|
+
'clear_scene',
|
|
14
|
+
'add',
|
|
15
|
+
'transform',
|
|
16
|
+
'duplicate',
|
|
17
|
+
'delete',
|
|
18
|
+
'join',
|
|
19
|
+
'bevel',
|
|
20
|
+
'solidify',
|
|
21
|
+
'array',
|
|
22
|
+
'mirror',
|
|
23
|
+
'boolean',
|
|
24
|
+
'material',
|
|
25
|
+
'assign_material',
|
|
26
|
+
'add_camera',
|
|
27
|
+
'add_light',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function isFile(value, existsSync = fs.existsSync) {
|
|
31
|
+
try { return Boolean(value) && existsSync(value) && fs.statSync(value).isFile(); }
|
|
32
|
+
catch { return false; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function canonicalExecutable(value) {
|
|
36
|
+
try { return fs.realpathSync.native ? fs.realpathSync.native(value) : fs.realpathSync(value); }
|
|
37
|
+
catch { return value; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pathCandidates(pathEnv, platform) {
|
|
41
|
+
const names = platform === 'win32' ? ['blender.exe', 'blender'] : ['blender'];
|
|
42
|
+
return String(pathEnv || '')
|
|
43
|
+
.split(path.delimiter)
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.flatMap(dir => names.map(name => path.join(dir, name)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function windowsInstallCandidates(env = process.env, readdirSync = fs.readdirSync) {
|
|
49
|
+
const roots = [env.ProgramFiles, env['ProgramFiles(x86)'], env.LOCALAPPDATA]
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.flatMap(root => [
|
|
52
|
+
path.join(root, 'Blender Foundation'),
|
|
53
|
+
path.join(root, 'Programs', 'Blender Foundation'),
|
|
54
|
+
]);
|
|
55
|
+
const found = [];
|
|
56
|
+
for (const root of roots) {
|
|
57
|
+
try {
|
|
58
|
+
const entries = readdirSync(root, { withFileTypes: true })
|
|
59
|
+
.filter(entry => entry.isDirectory() && /^Blender(?:\s|$)/i.test(entry.name))
|
|
60
|
+
.sort((a, b) => b.name.localeCompare(a.name, undefined, { numeric: true }));
|
|
61
|
+
for (const entry of entries) found.push(path.join(root, entry.name, 'blender.exe'));
|
|
62
|
+
found.push(path.join(root, 'blender.exe'));
|
|
63
|
+
} catch { /* optional search root */ }
|
|
64
|
+
}
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function discoverBlender({
|
|
69
|
+
blenderBin,
|
|
70
|
+
env = process.env,
|
|
71
|
+
platform = process.platform,
|
|
72
|
+
existsSync = fs.existsSync,
|
|
73
|
+
readdirSync = fs.readdirSync,
|
|
74
|
+
} = {}) {
|
|
75
|
+
const explicit = String(blenderBin || '').trim();
|
|
76
|
+
if (explicit) {
|
|
77
|
+
const resolved = path.resolve(explicit);
|
|
78
|
+
if (!isFile(resolved, existsSync)) throw new Error(`Blender binary not found: ${resolved}`);
|
|
79
|
+
return canonicalExecutable(resolved);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const configured = String(env.AGENTSAM_BLENDER_BIN || '').trim();
|
|
83
|
+
if (configured) {
|
|
84
|
+
const resolved = path.resolve(configured);
|
|
85
|
+
if (!isFile(resolved, existsSync)) throw new Error(`AGENTSAM_BLENDER_BIN does not exist: ${resolved}`);
|
|
86
|
+
return canonicalExecutable(resolved);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const candidates = [
|
|
90
|
+
...pathCandidates(env.PATH, platform),
|
|
91
|
+
...(platform === 'win32' ? windowsInstallCandidates(env, readdirSync) : []),
|
|
92
|
+
...(platform === 'darwin' ? ['/Applications/Blender.app/Contents/MacOS/Blender'] : []),
|
|
93
|
+
...(platform === 'linux' ? ['/usr/bin/blender', '/usr/local/bin/blender', '/snap/bin/blender'] : []),
|
|
94
|
+
];
|
|
95
|
+
const found = candidates.find(candidate => isFile(candidate, existsSync));
|
|
96
|
+
return found ? canonicalExecutable(found) : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function sha256File(file) {
|
|
100
|
+
const hash = createHash('sha256');
|
|
101
|
+
hash.update(fs.readFileSync(file));
|
|
102
|
+
return hash.digest('hex');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function boundedInteger(value, fallback, min, max, label) {
|
|
106
|
+
const parsed = value == null ? fallback : Number(value);
|
|
107
|
+
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
|
108
|
+
throw new Error(`${label} must be an integer from ${min} to ${max}`);
|
|
109
|
+
}
|
|
110
|
+
return parsed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveInputBlend(input, cwd = process.cwd()) {
|
|
114
|
+
const resolved = path.resolve(cwd, String(input || ''));
|
|
115
|
+
if (!String(input || '').trim()) throw new Error('Blender input file is required');
|
|
116
|
+
if (path.extname(resolved).toLowerCase() !== '.blend') throw new Error('Blender input must be a .blend file');
|
|
117
|
+
if (!isFile(resolved)) throw new Error(`Blender input file not found: ${resolved}`);
|
|
118
|
+
return resolved;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolveOutput(output, expectedExt, cwd = process.cwd(), input = null) {
|
|
122
|
+
if (!String(output || '').trim()) throw new Error('Output path is required');
|
|
123
|
+
const resolved = path.resolve(cwd, output);
|
|
124
|
+
if (path.extname(resolved).toLowerCase() !== expectedExt) {
|
|
125
|
+
throw new Error(`Output must end in ${expectedExt}`);
|
|
126
|
+
}
|
|
127
|
+
if (input && path.resolve(input) === resolved) throw new Error('Blender operations never overwrite the source .blend file');
|
|
128
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
129
|
+
return resolved;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function validateBlenderRecipe(recipe) {
|
|
133
|
+
if (!recipe || typeof recipe !== 'object' || Array.isArray(recipe)) throw new Error('Blender recipe must be a JSON object');
|
|
134
|
+
if (recipe.schema_version !== 1) throw new Error('Blender recipe schema_version must be 1');
|
|
135
|
+
if (!Array.isArray(recipe.operations) || recipe.operations.length < 1) throw new Error('Blender recipe operations must be a non-empty array');
|
|
136
|
+
if (recipe.operations.length > 256) throw new Error('Blender recipe may contain at most 256 operations');
|
|
137
|
+
for (let index = 0; index < recipe.operations.length; index += 1) {
|
|
138
|
+
const operation = recipe.operations[index];
|
|
139
|
+
if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw new Error(`recipe operation ${index} must be an object`);
|
|
140
|
+
const op = String(operation.op || '').trim();
|
|
141
|
+
if (!BLENDER_RECIPE_OPS.includes(op)) throw new Error(`unsupported Blender recipe operation at ${index}: ${op || '<empty>'}`);
|
|
142
|
+
}
|
|
143
|
+
return structuredClone(recipe);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function parseBlenderResult(stdout) {
|
|
147
|
+
const line = String(stdout || '').split(/\r?\n/).reverse().find(value => value.startsWith(BLENDER_RESULT_PREFIX));
|
|
148
|
+
if (!line) throw new Error('Blender did not return an AgentSam result envelope');
|
|
149
|
+
let value;
|
|
150
|
+
try { value = JSON.parse(line.slice(BLENDER_RESULT_PREFIX.length)); }
|
|
151
|
+
catch { throw new Error('Blender returned malformed AgentSam JSON'); }
|
|
152
|
+
if (!value || typeof value !== 'object') throw new Error('Blender returned an invalid AgentSam result envelope');
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createBlenderInvocation({ operation, input, requestPath, blenderBin, factoryStartup = false }) {
|
|
157
|
+
const args = ['--background'];
|
|
158
|
+
if (factoryStartup) args.push('--factory-startup');
|
|
159
|
+
if (input) args.push(input);
|
|
160
|
+
args.push('--python', BLENDER_ADAPTER_PATH, '--', '--operation', operation, '--request', requestPath);
|
|
161
|
+
return { command: blenderBin, args };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function invokeBlender({
|
|
165
|
+
operation,
|
|
166
|
+
input = null,
|
|
167
|
+
request = {},
|
|
168
|
+
blenderBin,
|
|
169
|
+
timeoutSeconds = 120,
|
|
170
|
+
cwd = process.cwd(),
|
|
171
|
+
factoryStartup = false,
|
|
172
|
+
runProcessImpl = runProcess,
|
|
173
|
+
}) {
|
|
174
|
+
const binary = discoverBlender({ blenderBin });
|
|
175
|
+
if (!binary) throw new Error('Blender is not installed or could not be discovered; use --blender-bin or AGENTSAM_BLENDER_BIN');
|
|
176
|
+
if (!fs.existsSync(BLENDER_ADAPTER_PATH)) throw new Error(`Bundled Blender adapter is missing: ${BLENDER_ADAPTER_PATH}`);
|
|
177
|
+
const timeout = boundedInteger(timeoutSeconds, 120, 1, 600, 'timeout');
|
|
178
|
+
const requestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-blender-'));
|
|
179
|
+
const requestPath = path.join(requestDir, 'request.json');
|
|
180
|
+
fs.writeFileSync(requestPath, JSON.stringify({ schema_version: 1, ...request }, null, 2), { mode: 0o600 });
|
|
181
|
+
const invocation = createBlenderInvocation({ operation, input, requestPath, blenderBin: binary, factoryStartup });
|
|
182
|
+
const started = Date.now();
|
|
183
|
+
try {
|
|
184
|
+
const proc = await runProcessImpl(invocation.command, invocation.args, {
|
|
185
|
+
cwd,
|
|
186
|
+
timeoutMs: timeout * 1000,
|
|
187
|
+
maxBytes: 4 * 1024 * 1024,
|
|
188
|
+
});
|
|
189
|
+
let result;
|
|
190
|
+
try { result = parseBlenderResult(proc.stdout); }
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (proc.code !== 0) throw new Error(`Blender ${operation} failed with exit ${proc.code}: ${(proc.stderr || proc.stdout || '').slice(-4000)}`);
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
if (proc.code !== 0 || result.ok === false) throw new Error(result.error || `Blender ${operation} failed with exit ${proc.code}`);
|
|
196
|
+
return {
|
|
197
|
+
result,
|
|
198
|
+
binary,
|
|
199
|
+
duration_ms: Date.now() - started,
|
|
200
|
+
logs: String(proc.stderr || '').slice(-8000),
|
|
201
|
+
};
|
|
202
|
+
} finally {
|
|
203
|
+
fs.rmSync(requestDir, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function blenderStatus({ blenderBin, runProcessImpl = runProcess } = {}) {
|
|
208
|
+
let binary;
|
|
209
|
+
try { binary = discoverBlender({ blenderBin }); }
|
|
210
|
+
catch (error) {
|
|
211
|
+
return { schema_version: 1, capability: 'blender.status', available: false, binary: null, version: null, execution_lane: 'native', error: error.message };
|
|
212
|
+
}
|
|
213
|
+
if (!binary) return { schema_version: 1, capability: 'blender.status', available: false, binary: null, version: null, execution_lane: 'native' };
|
|
214
|
+
const proc = await runProcessImpl(binary, ['--version'], { timeoutMs: 10_000, maxBytes: 256 * 1024 });
|
|
215
|
+
const version = String(proc.stdout || proc.stderr || '').split(/\r?\n/).find(Boolean)?.trim() || null;
|
|
216
|
+
return { schema_version: 1, capability: 'blender.status', available: proc.code === 0, binary, version, execution_lane: 'native' };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function inputReceipt(input) {
|
|
220
|
+
return input ? { path: input, sha256: sha256File(input) } : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function artifactReceipt(file, format) {
|
|
224
|
+
const stat = fs.statSync(file);
|
|
225
|
+
return { path: file, format, size_bytes: stat.size, sha256: sha256File(file) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function blenderInspect({ input, blenderBin, timeoutSeconds, cwd = process.cwd(), runProcessImpl } = {}) {
|
|
229
|
+
const source = resolveInputBlend(input, cwd);
|
|
230
|
+
const inputInfo = inputReceipt(source);
|
|
231
|
+
const run = await invokeBlender({ operation: 'inspect', input: source, blenderBin, timeoutSeconds, cwd, runProcessImpl });
|
|
232
|
+
return {
|
|
233
|
+
schema_version: 1,
|
|
234
|
+
capability: 'blender.inspect',
|
|
235
|
+
ok: true,
|
|
236
|
+
execution_lane: 'native',
|
|
237
|
+
input: inputInfo,
|
|
238
|
+
blender: { binary: run.binary, version: run.result.blender_version || null },
|
|
239
|
+
scene: run.result.scene,
|
|
240
|
+
duration_ms: run.duration_ms,
|
|
241
|
+
warnings: run.result.warnings || [],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function blenderBuild({ input, output, recipe, blenderBin, timeoutSeconds, cwd = process.cwd(), runProcessImpl } = {}) {
|
|
246
|
+
const source = input ? resolveInputBlend(input, cwd) : null;
|
|
247
|
+
const target = resolveOutput(output, '.blend', cwd, source);
|
|
248
|
+
const normalizedRecipe = validateBlenderRecipe(recipe);
|
|
249
|
+
const run = await invokeBlender({
|
|
250
|
+
operation: 'build',
|
|
251
|
+
input: source,
|
|
252
|
+
request: { output: target, recipe: normalizedRecipe },
|
|
253
|
+
blenderBin,
|
|
254
|
+
timeoutSeconds,
|
|
255
|
+
cwd,
|
|
256
|
+
factoryStartup: !source,
|
|
257
|
+
runProcessImpl,
|
|
258
|
+
});
|
|
259
|
+
if (!isFile(target)) throw new Error(`Blender build did not create output: ${target}`);
|
|
260
|
+
return {
|
|
261
|
+
schema_version: 1,
|
|
262
|
+
capability: 'blender.build',
|
|
263
|
+
ok: true,
|
|
264
|
+
execution_lane: 'native',
|
|
265
|
+
input: inputReceipt(source),
|
|
266
|
+
blender: { binary: run.binary, version: run.result.blender_version || null },
|
|
267
|
+
artifact: artifactReceipt(target, 'blend'),
|
|
268
|
+
operations_applied: run.result.operations_applied ?? normalizedRecipe.operations.length,
|
|
269
|
+
objects: run.result.objects || [],
|
|
270
|
+
duration_ms: run.duration_ms,
|
|
271
|
+
warnings: run.result.warnings || [],
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function blenderRenderPreview({ input, output, scene, camera, width = 1024, height = 1024, engine, blenderBin, timeoutSeconds, cwd = process.cwd(), runProcessImpl } = {}) {
|
|
276
|
+
const source = resolveInputBlend(input, cwd);
|
|
277
|
+
const target = resolveOutput(output, '.png', cwd, source);
|
|
278
|
+
const request = {
|
|
279
|
+
output: target,
|
|
280
|
+
scene: scene || null,
|
|
281
|
+
camera: camera || null,
|
|
282
|
+
width: boundedInteger(width, 1024, 64, 4096, 'width'),
|
|
283
|
+
height: boundedInteger(height, 1024, 64, 4096, 'height'),
|
|
284
|
+
engine: engine || null,
|
|
285
|
+
};
|
|
286
|
+
const run = await invokeBlender({ operation: 'render_preview', input: source, request, blenderBin, timeoutSeconds, cwd, runProcessImpl });
|
|
287
|
+
if (!isFile(target)) throw new Error(`Blender render did not create output: ${target}`);
|
|
288
|
+
return {
|
|
289
|
+
schema_version: 1,
|
|
290
|
+
capability: 'blender.render_preview',
|
|
291
|
+
ok: true,
|
|
292
|
+
execution_lane: 'native',
|
|
293
|
+
input: inputReceipt(source),
|
|
294
|
+
blender: { binary: run.binary, version: run.result.blender_version || null },
|
|
295
|
+
artifact: artifactReceipt(target, 'png'),
|
|
296
|
+
scene: run.result.scene || null,
|
|
297
|
+
camera: run.result.camera || null,
|
|
298
|
+
duration_ms: run.duration_ms,
|
|
299
|
+
warnings: run.result.warnings || [],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function blenderExport({ input, output, format, scene, objects, collection, applyModifiers = true, blenderBin, timeoutSeconds, cwd = process.cwd(), runProcessImpl } = {}) {
|
|
304
|
+
const source = resolveInputBlend(input, cwd);
|
|
305
|
+
const normalizedFormat = String(format || '').toLowerCase().replace(/^\./, '');
|
|
306
|
+
if (!BLENDER_EXPORT_FORMATS.includes(normalizedFormat)) throw new Error(`Unsupported Blender export format: ${normalizedFormat || '<empty>'}. Expected ${BLENDER_EXPORT_FORMATS.join(', ')}`);
|
|
307
|
+
const target = resolveOutput(output, `.${normalizedFormat}`, cwd, source);
|
|
308
|
+
const selectedObjects = Array.isArray(objects)
|
|
309
|
+
? objects.map(String).filter(Boolean)
|
|
310
|
+
: String(objects || '').split(',').map(value => value.trim()).filter(Boolean);
|
|
311
|
+
const run = await invokeBlender({
|
|
312
|
+
operation: 'export',
|
|
313
|
+
input: source,
|
|
314
|
+
request: {
|
|
315
|
+
output: target,
|
|
316
|
+
format: normalizedFormat,
|
|
317
|
+
scene: scene || null,
|
|
318
|
+
objects: selectedObjects,
|
|
319
|
+
collection: collection || null,
|
|
320
|
+
apply_modifiers: Boolean(applyModifiers),
|
|
321
|
+
},
|
|
322
|
+
blenderBin,
|
|
323
|
+
timeoutSeconds,
|
|
324
|
+
cwd,
|
|
325
|
+
runProcessImpl,
|
|
326
|
+
});
|
|
327
|
+
if (!isFile(target)) throw new Error(`Blender export did not create output: ${target}`);
|
|
328
|
+
return {
|
|
329
|
+
schema_version: 1,
|
|
330
|
+
capability: 'blender.export',
|
|
331
|
+
ok: true,
|
|
332
|
+
execution_lane: 'native',
|
|
333
|
+
input: inputReceipt(source),
|
|
334
|
+
blender: { binary: run.binary, version: run.result.blender_version || null },
|
|
335
|
+
artifact: artifactReceipt(target, normalizedFormat),
|
|
336
|
+
selected_objects: run.result.selected_objects || [],
|
|
337
|
+
duration_ms: run.duration_ms,
|
|
338
|
+
warnings: run.result.warnings || [],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
BLENDER_ADAPTER_PATH,
|
|
3
|
+
BLENDER_EXPORT_FORMATS,
|
|
4
|
+
BLENDER_RECIPE_OPS,
|
|
5
|
+
blenderBuild,
|
|
6
|
+
blenderExport,
|
|
7
|
+
blenderInspect,
|
|
8
|
+
blenderRenderPreview,
|
|
9
|
+
blenderStatus,
|
|
10
|
+
createBlenderInvocation,
|
|
11
|
+
discoverBlender,
|
|
12
|
+
parseBlenderResult,
|
|
13
|
+
sha256File,
|
|
14
|
+
validateBlenderRecipe,
|
|
15
|
+
} from './blender.js';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
function normalizeHttpUrl(value) {
|
|
5
|
+
const raw = String(value || '').trim();
|
|
6
|
+
if (!raw) throw new Error('URL is required');
|
|
7
|
+
let parsed;
|
|
8
|
+
try { parsed = new URL(raw); }
|
|
9
|
+
catch { throw new Error(`Invalid URL: ${raw}`); }
|
|
10
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
11
|
+
throw new Error(`Unsupported URL protocol: ${parsed.protocol}`);
|
|
12
|
+
}
|
|
13
|
+
return parsed.toString();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function browserCommand(url, platform = process.platform) {
|
|
17
|
+
const normalized = normalizeHttpUrl(url);
|
|
18
|
+
if (platform === 'darwin') return { command: 'open', args: [normalized] };
|
|
19
|
+
if (platform === 'win32') return { command: 'cmd', args: ['/c', 'start', '', normalized] };
|
|
20
|
+
return { command: 'xdg-open', args: [normalized] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function openExternalUrl(url, {
|
|
24
|
+
platform = process.platform,
|
|
25
|
+
spawnImpl = spawn,
|
|
26
|
+
} = {}) {
|
|
27
|
+
const invocation = browserCommand(url, platform);
|
|
28
|
+
const child = spawnImpl(invocation.command, invocation.args, {
|
|
29
|
+
stdio: 'ignore',
|
|
30
|
+
detached: true,
|
|
31
|
+
});
|
|
32
|
+
child.unref?.();
|
|
33
|
+
return invocation;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function promptToOpenUrl(url, {
|
|
37
|
+
heading = 'Open in your browser:',
|
|
38
|
+
prompt = 'Press ENTER to open in the browser, or copy the URL above.',
|
|
39
|
+
input = process.stdin,
|
|
40
|
+
output = process.stdout,
|
|
41
|
+
openImpl = openExternalUrl,
|
|
42
|
+
} = {}) {
|
|
43
|
+
const normalized = normalizeHttpUrl(url);
|
|
44
|
+
output.write(`\n${heading}\n${normalized}\n`);
|
|
45
|
+
|
|
46
|
+
if (!input?.isTTY || !output?.isTTY) {
|
|
47
|
+
output.write('Open the URL above in a browser to continue.\n\n');
|
|
48
|
+
return { url: normalized, opened: false, interactive: false };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rl = readline.createInterface({ input, output });
|
|
52
|
+
try {
|
|
53
|
+
await new Promise(resolve => rl.question(`\n${prompt}\n`, resolve));
|
|
54
|
+
} finally {
|
|
55
|
+
rl.close();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
openImpl(normalized);
|
|
60
|
+
output.write('\nBrowser opened. Complete the step there, then return here.\n\n');
|
|
61
|
+
return { url: normalized, opened: true, interactive: true };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
output.write(`\nCould not open the browser automatically: ${error.message}\nOpen the URL above manually.\n\n`);
|
|
64
|
+
return { url: normalized, opened: false, interactive: true, error: error.message };
|
|
65
|
+
}
|
|
66
|
+
}
|