@causyne/agent 1.10.19 → 1.10.20

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.
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ const exec = promisify(execFile);
7
+ export function blenderCandidates(platform = process.platform) {
8
+ if (platform === 'darwin')
9
+ return ['/Applications/Blender.app/Contents/MacOS/Blender', '/opt/homebrew/bin/blender', '/usr/local/bin/blender'];
10
+ if (platform === 'win32') {
11
+ const root = path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Blender Foundation');
12
+ try {
13
+ return fs.readdirSync(root).filter(n => /^Blender(?:\s|$)/i.test(n)).sort().reverse().map(n => path.join(root, n, 'blender.exe'));
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ }
19
+ return ['/usr/bin/blender', '/usr/local/bin/blender', '/snap/bin/blender'];
20
+ }
21
+ export async function findBlender() {
22
+ for (const executable of blenderCandidates()) {
23
+ if (!fs.existsSync(executable))
24
+ continue;
25
+ try {
26
+ const { stdout } = await exec(executable, ['--version'], { timeout: 8000, maxBuffer: 64 * 1024 });
27
+ const version = stdout.match(/^Blender\s+\d[^\r\n]*/)?.[0];
28
+ if (version)
29
+ return { executable, version };
30
+ }
31
+ catch { /* Try the next installed candidate. Never install implicitly. */ }
32
+ }
33
+ return null;
34
+ }
35
+ /** Probes local creative application installations and native automation adapter readiness. */
36
+ export async function creativeAppStatus() {
37
+ const blender = await findBlender();
38
+ const roots = process.platform === 'darwin'
39
+ ? ['/Applications', path.join(os.homedir(), 'Applications')]
40
+ : process.platform === 'win32'
41
+ ? [process.env.ProgramFiles || 'C:\\Program Files', process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)']
42
+ : [];
43
+ const installedNames = roots.flatMap(root => { try {
44
+ return fs.readdirSync(root);
45
+ }
46
+ catch {
47
+ return [];
48
+ } });
49
+ return [
50
+ { id: 'blender', label: '3D Studio (Blender)', installed: Boolean(blender), available: Boolean(blender),
51
+ detail: blender ? `${blender.version} · native batch engine (geometry, materials, lighting, render)` : 'Install Blender to enable 3D batch modeling and rendering.' },
52
+ ...[
53
+ ['photoshop', 'Photo Studio', /Photoshop/i, '.psd/.png/.jpg', 'layer edits, transforms, resizing, flattening'],
54
+ ['premiere', 'Video Studio', /Premiere/i, '.prproj', 'sequences, timeline media imports, cuts, markers'],
55
+ ['after-effects', 'Motion Studio', /After\s*Effects/i, '.aep', 'compositions, footage import, typography, motion']
56
+ ].map(([id, label, pattern, ext, capabilities]) => {
57
+ const installed = installedNames.some(n => pattern.test(n));
58
+ return {
59
+ id: id,
60
+ label: label,
61
+ installed,
62
+ available: installed,
63
+ detail: installed
64
+ ? `Connected · native automation engine ready (${ext} · ${capabilities})`
65
+ : `Not installed · install ${label} host engine to enable native creative automation`
66
+ };
67
+ })
68
+ ];
69
+ }
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ const exec = promisify(execFile);
7
+
8
+ export function blenderCandidates(platform = process.platform): string[] {
9
+ if (platform === 'darwin') return ['/Applications/Blender.app/Contents/MacOS/Blender', '/opt/homebrew/bin/blender', '/usr/local/bin/blender'];
10
+ if (platform === 'win32') {
11
+ const root = path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Blender Foundation');
12
+ try { return fs.readdirSync(root).filter(n => /^Blender(?:\s|$)/i.test(n)).sort().reverse().map(n => path.join(root, n, 'blender.exe')); } catch { return []; }
13
+ }
14
+ return ['/usr/bin/blender', '/usr/local/bin/blender', '/snap/bin/blender'];
15
+ }
16
+
17
+ export async function findBlender(): Promise<{ executable: string; version: string } | null> {
18
+ for (const executable of blenderCandidates()) {
19
+ if (!fs.existsSync(executable)) continue;
20
+ try {
21
+ const { stdout } = await exec(executable, ['--version'], { timeout: 8000, maxBuffer: 64 * 1024 });
22
+ const version = stdout.match(/^Blender\s+\d[^\r\n]*/)?.[0];
23
+ if (version) return { executable, version };
24
+ } catch { /* Try the next installed candidate. Never install implicitly. */ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /** Probes local creative application installations and native automation adapter readiness. */
30
+ export async function creativeAppStatus() {
31
+ const blender = await findBlender();
32
+ const roots = process.platform === 'darwin'
33
+ ? ['/Applications', path.join(os.homedir(), 'Applications')]
34
+ : process.platform === 'win32'
35
+ ? [process.env.ProgramFiles || 'C:\\Program Files', process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)']
36
+ : [];
37
+ const installedNames = roots.flatMap(root => { try { return fs.readdirSync(root); } catch { return []; } });
38
+ return [
39
+ { id: 'blender', label: '3D Studio (Blender)', installed: Boolean(blender), available: Boolean(blender),
40
+ detail: blender ? `${blender.version} · native batch engine (geometry, materials, lighting, render)` : 'Install Blender to enable 3D batch modeling and rendering.' },
41
+ ...[
42
+ ['photoshop', 'Photo Studio', /Photoshop/i, '.psd/.png/.jpg', 'layer edits, transforms, resizing, flattening'],
43
+ ['premiere', 'Video Studio', /Premiere/i, '.prproj', 'sequences, timeline media imports, cuts, markers'],
44
+ ['after-effects', 'Motion Studio', /After\s*Effects/i, '.aep', 'compositions, footage import, typography, motion']
45
+ ].map(([id, label, pattern, ext, capabilities]) => {
46
+ const installed = installedNames.some(n => (pattern as RegExp).test(n));
47
+ return {
48
+ id: id as string,
49
+ label: label as string,
50
+ installed,
51
+ available: installed,
52
+ detail: installed
53
+ ? `Connected · native automation engine ready (${ext} · ${capabilities})`
54
+ : `Not installed · install ${label} host engine to enable native creative automation`
55
+ };
56
+ })
57
+ ];
58
+ }
@@ -0,0 +1,233 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { execFile } from 'node:child_process';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { promisify } from 'node:util';
7
+ import { findBlender } from './apps.js';
8
+ const exec = promisify(execFile);
9
+ function record(v) {
10
+ if (!v || typeof v !== 'object' || Array.isArray(v))
11
+ throw new Error('Expected a JSON object');
12
+ return v;
13
+ }
14
+ function vector(v) {
15
+ if (!Array.isArray(v) || v.length !== 3 || !v.every(x => typeof x === 'number' && Number.isFinite(x) && Math.abs(x) <= 1e6))
16
+ throw new Error('A transform requires three finite numbers within +/- 1000000');
17
+ return v;
18
+ }
19
+ function color(v) {
20
+ if (!Array.isArray(v) || v.length !== 4 || !v.every(x => typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1))
21
+ throw new Error('A color requires 4 numbers between 0 and 1 [R, G, B, A]');
22
+ return v;
23
+ }
24
+ export function parseBatch(value) {
25
+ const job = record(value);
26
+ if (typeof job.output !== 'string' || !job.output.endsWith('.blend'))
27
+ throw new Error('output must be a new .blend file');
28
+ if (job.input !== undefined && (typeof job.input !== 'string' || !job.input.endsWith('.blend')))
29
+ throw new Error('input must be a .blend file');
30
+ if (!Array.isArray(job.actions) || job.actions.length > 64)
31
+ throw new Error('Provide up to 64 actions');
32
+ const actions = job.actions.map((raw) => {
33
+ const a = record(raw);
34
+ if (typeof a.name !== 'string' || !a.name.trim() || a.name.length > 128 || /[\x00-\x1f]/.test(a.name))
35
+ throw new Error('Each action needs an object name (1-128 characters)');
36
+ if (a.type === 'add_cube')
37
+ return { type: a.type, name: a.name, location: a.location === undefined ? [0, 0, 0] : vector(a.location), scale: a.scale === undefined ? [1, 1, 1] : vector(a.scale) };
38
+ if (a.type === 'add_sphere')
39
+ return { type: a.type, name: a.name, location: a.location === undefined ? [0, 0, 0] : vector(a.location), scale: a.scale === undefined ? [1, 1, 1] : vector(a.scale), radius: typeof a.radius === 'number' && Number.isFinite(a.radius) && a.radius > 0 ? a.radius : 1 };
40
+ if (a.type === 'add_cylinder')
41
+ return { type: a.type, name: a.name, location: a.location === undefined ? [0, 0, 0] : vector(a.location), scale: a.scale === undefined ? [1, 1, 1] : vector(a.scale), radius: typeof a.radius === 'number' && Number.isFinite(a.radius) && a.radius > 0 ? a.radius : 1, depth: typeof a.depth === 'number' && Number.isFinite(a.depth) && a.depth > 0 ? a.depth : 2 };
42
+ if (a.type === 'add_plane')
43
+ return { type: a.type, name: a.name, location: a.location === undefined ? [0, 0, 0] : vector(a.location), scale: a.scale === undefined ? [1, 1, 1] : vector(a.scale) };
44
+ if (a.type === 'add_camera')
45
+ return { type: a.type, name: a.name, location: a.location === undefined ? [0, -10, 5] : vector(a.location), ...(a.rotation !== undefined ? { rotation: vector(a.rotation) } : {}) };
46
+ if (a.type === 'add_light')
47
+ return { type: a.type, name: a.name, location: a.location === undefined ? [4, 1, 6] : vector(a.location), lightType: typeof a.lightType === 'string' && ['POINT', 'SUN', 'SPOT', 'AREA'].includes(a.lightType) ? a.lightType : 'POINT', ...(typeof a.energy === 'number' && Number.isFinite(a.energy) ? { energy: a.energy } : {}) };
48
+ if (a.type === 'material') {
49
+ return {
50
+ type: a.type,
51
+ name: a.name,
52
+ color: color(a.color),
53
+ ...(typeof a.roughness === 'number' && Number.isFinite(a.roughness) && a.roughness >= 0 && a.roughness <= 1 ? { roughness: a.roughness } : {}),
54
+ ...(typeof a.metallic === 'number' && Number.isFinite(a.metallic) && a.metallic >= 0 && a.metallic <= 1 ? { metallic: a.metallic } : {})
55
+ };
56
+ }
57
+ if (a.type === 'render') {
58
+ if (typeof a.outputPath !== 'string' || !a.outputPath.trim())
59
+ throw new Error('render action requires an outputPath');
60
+ return { type: a.type, name: a.name, outputPath: a.outputPath, ...(Array.isArray(a.resolution) && a.resolution.length === 2 && a.resolution.every(n => typeof n === 'number' && n > 0) ? { resolution: [a.resolution[0], a.resolution[1]] } : {}) };
61
+ }
62
+ if (a.type === 'transform') {
63
+ if (a.location === undefined && a.scale === undefined && a.rotation === undefined)
64
+ throw new Error('transform requires location or scale');
65
+ return { type: a.type, name: a.name, ...(a.location === undefined ? {} : { location: vector(a.location) }), ...(a.scale === undefined ? {} : { scale: vector(a.scale) }), ...(a.rotation === undefined ? {} : { rotation: vector(a.rotation) }) };
66
+ }
67
+ throw new Error('Unsupported batch action');
68
+ });
69
+ return { output: job.output, actions, ...(typeof job.input === 'string' ? { input: job.input } : {}) };
70
+ }
71
+ const inside = (root, file) => {
72
+ const relative = path.relative(root, file);
73
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
74
+ };
75
+ async function scopedPath(root, name, existing) {
76
+ const target = path.resolve(root, name);
77
+ const resolved = existing ? await fs.realpath(target) : path.join(await fs.realpath(path.dirname(target)), path.basename(target));
78
+ if (!inside(root, resolved))
79
+ throw new Error('Creative files must stay inside the selected workspace');
80
+ if (!existing) {
81
+ try {
82
+ await fs.lstat(target);
83
+ throw new Error('Output already exists; select a new filename');
84
+ }
85
+ catch (error) {
86
+ if (error.code !== 'ENOENT')
87
+ throw error;
88
+ }
89
+ }
90
+ return resolved;
91
+ }
92
+ // Data and executable Python are separate files. No model-supplied Python is evaluated.
93
+ const SCRIPT = `import bpy, json, sys
94
+ from pathlib import Path
95
+ job = json.loads(Path(sys.argv[sys.argv.index('--') + 1]).read_text())
96
+ if job.get('input'):
97
+ bpy.ops.wm.open_mainfile(filepath=job['input'], load_ui=False, use_scripts=False)
98
+ else:
99
+ bpy.ops.wm.read_factory_settings(use_empty=True)
100
+ for a in job['actions']:
101
+ t = a['type']
102
+ if t == 'add_cube':
103
+ if bpy.data.objects.get(a['name']):
104
+ raise ValueError('Object already exists: ' + a['name'])
105
+ bpy.ops.mesh.primitive_cube_add(location=a['location'])
106
+ obj = bpy.context.object
107
+ obj.name = a['name']
108
+ obj.scale = a['scale']
109
+ elif t == 'add_sphere':
110
+ if bpy.data.objects.get(a['name']):
111
+ raise ValueError('Object already exists: ' + a['name'])
112
+ bpy.ops.mesh.primitive_uv_sphere_add(location=a.get('location', [0, 0, 0]), radius=a.get('radius', 1.0))
113
+ obj = bpy.context.object
114
+ obj.name = a['name']
115
+ obj.scale = a['scale']
116
+ elif t == 'add_cylinder':
117
+ if bpy.data.objects.get(a['name']):
118
+ raise ValueError('Object already exists: ' + a['name'])
119
+ bpy.ops.mesh.primitive_cylinder_add(location=a.get('location', [0, 0, 0]), radius=a.get('radius', 1.0), depth=a.get('depth', 2.0))
120
+ obj = bpy.context.object
121
+ obj.name = a['name']
122
+ obj.scale = a['scale']
123
+ elif t == 'add_plane':
124
+ if bpy.data.objects.get(a['name']):
125
+ raise ValueError('Object already exists: ' + a['name'])
126
+ bpy.ops.mesh.primitive_plane_add(location=a.get('location', [0, 0, 0]))
127
+ obj = bpy.context.object
128
+ obj.name = a['name']
129
+ obj.scale = a['scale']
130
+ elif t == 'add_camera':
131
+ if bpy.data.objects.get(a['name']):
132
+ raise ValueError('Object already exists: ' + a['name'])
133
+ bpy.ops.object.camera_add(location=a.get('location', [0, -10, 5]))
134
+ cam = bpy.context.object
135
+ cam.name = a['name']
136
+ bpy.context.scene.camera = cam
137
+ elif t == 'add_light':
138
+ if bpy.data.objects.get(a['name']):
139
+ raise ValueError('Object already exists: ' + a['name'])
140
+ ltype = a.get('lightType', 'POINT')
141
+ bpy.ops.object.light_add(type=ltype, location=a.get('location', [4, 1, 6]))
142
+ lt = bpy.context.object
143
+ lt.name = a['name']
144
+ if 'energy' in a and hasattr(lt.data, 'energy'):
145
+ lt.data.energy = a['energy']
146
+ elif t == 'material':
147
+ obj = bpy.data.objects.get(a['name'])
148
+ if obj is None:
149
+ raise ValueError('Object not found: ' + a['name'])
150
+ mat = bpy.data.materials.new(name=a['name'] + '_mat')
151
+ mat.use_nodes = True
152
+ bsdf = mat.node_tree.nodes.get('Principled BSDF')
153
+ if bsdf:
154
+ bsdf.inputs['Base Color'].default_value = a['color']
155
+ if 'roughness' in a and 'Roughness' in bsdf.inputs:
156
+ bsdf.inputs['Roughness'].default_value = a['roughness']
157
+ if 'metallic' in a and 'Metallic' in bsdf.inputs:
158
+ bsdf.inputs['Metallic'].default_value = a['metallic']
159
+ if obj.data and hasattr(obj.data, 'materials'):
160
+ obj.data.materials.append(mat)
161
+ elif t == 'transform':
162
+ obj = bpy.data.objects.get(a['name'])
163
+ if obj is None:
164
+ raise ValueError('Object not found: ' + a['name'])
165
+ if 'location' in a: obj.location = a['location']
166
+ if 'scale' in a: obj.scale = a['scale']
167
+ if 'rotation' in a: obj.rotation_euler = a['rotation']
168
+ elif t == 'render':
169
+ if 'outputPath' in a:
170
+ bpy.context.scene.render.filepath = a['outputPath']
171
+ if 'resolution' in a:
172
+ bpy.context.scene.render.resolution_x = a['resolution'][0]
173
+ bpy.context.scene.render.resolution_y = a['resolution'][1]
174
+ bpy.ops.render.render(write_still=True)
175
+ bpy.context.view_layer.update()
176
+ bpy.context.preferences.filepaths.save_version = 0
177
+ bpy.ops.wm.save_as_mainfile(filepath=job['stagedOutput'], check_existing=False, compress=False)
178
+ scene = [{'name': o.name, 'type': o.type, 'location': list(o.location), 'scale': list(o.scale)} for o in list(bpy.context.scene.objects)[:200]]
179
+ Path(job['result']).write_text(json.dumps({'objects': scene, 'totalObjects': len(bpy.context.scene.objects)}))
180
+ `;
181
+ export async function runBlenderBatch(value, workspace = process.cwd(), signal) {
182
+ signal?.throwIfAborted();
183
+ const job = parseBatch(value);
184
+ const root = await fs.realpath(workspace);
185
+ const output = await scopedPath(root, job.output, false);
186
+ const input = job.input ? await scopedPath(root, job.input, true) : undefined;
187
+ for (const action of job.actions) {
188
+ if (action.type === 'render') {
189
+ action.outputPath = await scopedPath(root, action.outputPath, false);
190
+ }
191
+ }
192
+ const engine = await findBlender();
193
+ if (!engine)
194
+ throw new Error('Blender is not installed at a supported location');
195
+ const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'causyne-creative-'));
196
+ const stagedOutput = path.join(path.dirname(output), `.causyne-scene-${randomUUID()}.blend`);
197
+ let reserved = false;
198
+ try {
199
+ const reservation = await fs.open(stagedOutput, 'wx', 0o600);
200
+ await reservation.close();
201
+ reserved = true;
202
+ const script = path.join(temp, 'batch.py');
203
+ const request = path.join(temp, 'job.json');
204
+ const resultFile = path.join(temp, 'result.json');
205
+ await fs.writeFile(script, SCRIPT, { mode: 0o600 });
206
+ await fs.writeFile(request, JSON.stringify({ ...job, input, stagedOutput, result: resultFile }), { mode: 0o600 });
207
+ const env = Object.fromEntries(['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'TEMP', 'TMP', 'TMPDIR', 'LANG'].flatMap(key => process.env[key] ? [[key, process.env[key]]] : []));
208
+ await exec(engine.executable, ['--background', '--factory-startup', '--disable-autoexec', '--python-exit-code', '1', '--python', script, '--', request], {
209
+ cwd: root, timeout: 120000, maxBuffer: 1024 * 1024, windowsHide: true, env, ...(signal ? { signal } : {})
210
+ });
211
+ const summary = JSON.parse(await fs.readFile(resultFile, 'utf8'));
212
+ const handle = await fs.open(stagedOutput, 'r');
213
+ try {
214
+ const header = Buffer.alloc(7);
215
+ await handle.read(header, 0, 7, 0);
216
+ if (header.toString() !== 'BLENDER')
217
+ throw new Error('Blender did not produce a valid .blend header');
218
+ }
219
+ finally {
220
+ await handle.close();
221
+ }
222
+ // Recheck scope after the process returns; never overwrite an existing project.
223
+ signal?.throwIfAborted();
224
+ await scopedPath(root, output, false);
225
+ await fs.copyFile(stagedOutput, output, fs.constants.COPYFILE_EXCL);
226
+ return { success: true, output, version: engine.version, summary };
227
+ }
228
+ finally {
229
+ if (reserved)
230
+ await fs.rm(stagedOutput, { force: true });
231
+ await fs.rm(temp, { recursive: true, force: true });
232
+ }
233
+ }
@@ -0,0 +1,217 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { execFile } from 'node:child_process';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { promisify } from 'node:util';
7
+ import { findBlender } from './apps.js';
8
+ const exec = promisify(execFile);
9
+ type Vector = [number, number, number];
10
+ type Color = [number, number, number, number];
11
+ type Action =
12
+ | { type: 'add_cube'; name: string; location: Vector; scale: Vector }
13
+ | { type: 'add_sphere'; name: string; location: Vector; scale: Vector; radius: number }
14
+ | { type: 'add_cylinder'; name: string; location: Vector; scale: Vector; radius: number; depth: number }
15
+ | { type: 'add_plane'; name: string; location: Vector; scale: Vector }
16
+ | { type: 'add_camera'; name: string; location: Vector; rotation?: Vector | undefined }
17
+ | { type: 'add_light'; name: string; location: Vector; lightType: string; energy?: number | undefined }
18
+ | { type: 'material'; name: string; color: Color; roughness?: number | undefined; metallic?: number | undefined }
19
+ | { type: 'render'; name: string; outputPath: string; resolution?: [number, number] | undefined }
20
+ | { type: 'transform'; name: string; location?: Vector | undefined; scale?: Vector | undefined; rotation?: Vector | undefined };
21
+ export interface Batch { input?: string | undefined; output: string; actions: Action[] }
22
+ function record(v: unknown): Record<string, unknown> {
23
+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('Expected a JSON object');
24
+ return v as Record<string, unknown>;
25
+ }
26
+ function vector(v: unknown): Vector {
27
+ if (!Array.isArray(v) || v.length !== 3 || !v.every(x => typeof x === 'number' && Number.isFinite(x) && Math.abs(x) <= 1e6)) throw new Error('A transform requires three finite numbers within +/- 1000000');
28
+ return v as Vector;
29
+ }
30
+ function color(v: unknown): Color {
31
+ if (!Array.isArray(v) || v.length !== 4 || !v.every(x => typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1)) throw new Error('A color requires 4 numbers between 0 and 1 [R, G, B, A]');
32
+ return v as Color;
33
+ }
34
+ export function parseBatch(value: unknown): Batch {
35
+ const job = record(value);
36
+ if (typeof job.output !== 'string' || !job.output.endsWith('.blend')) throw new Error('output must be a new .blend file');
37
+ if (job.input !== undefined && (typeof job.input !== 'string' || !job.input.endsWith('.blend'))) throw new Error('input must be a .blend file');
38
+ if (!Array.isArray(job.actions) || job.actions.length > 64) throw new Error('Provide up to 64 actions');
39
+ const actions = job.actions.map((raw): Action => {
40
+ const a = record(raw);
41
+ if (typeof a.name !== 'string' || !a.name.trim() || a.name.length > 128 || /[\x00-\x1f]/.test(a.name)) throw new Error('Each action needs an object name (1-128 characters)');
42
+ if (a.type === 'add_cube') return { type: a.type, name: a.name, location: a.location === undefined ? [0, 0, 0] : vector(a.location), scale: a.scale === undefined ? [1, 1, 1] : vector(a.scale) };
43
+ if (a.type === 'add_sphere') return { type: a.type, name: a.name, location: a.location === undefined ? [0, 0, 0] : vector(a.location), scale: a.scale === undefined ? [1, 1, 1] : vector(a.scale), radius: typeof a.radius === 'number' && Number.isFinite(a.radius) && a.radius > 0 ? a.radius : 1 };
44
+ if (a.type === 'add_cylinder') return { type: a.type, name: a.name, location: a.location === undefined ? [0, 0, 0] : vector(a.location), scale: a.scale === undefined ? [1, 1, 1] : vector(a.scale), radius: typeof a.radius === 'number' && Number.isFinite(a.radius) && a.radius > 0 ? a.radius : 1, depth: typeof a.depth === 'number' && Number.isFinite(a.depth) && a.depth > 0 ? a.depth : 2 };
45
+ if (a.type === 'add_plane') return { type: a.type, name: a.name, location: a.location === undefined ? [0, 0, 0] : vector(a.location), scale: a.scale === undefined ? [1, 1, 1] : vector(a.scale) };
46
+ if (a.type === 'add_camera') return { type: a.type, name: a.name, location: a.location === undefined ? [0, -10, 5] : vector(a.location), ...(a.rotation !== undefined ? { rotation: vector(a.rotation) } : {}) };
47
+ if (a.type === 'add_light') return { type: a.type, name: a.name, location: a.location === undefined ? [4, 1, 6] : vector(a.location), lightType: typeof a.lightType === 'string' && ['POINT', 'SUN', 'SPOT', 'AREA'].includes(a.lightType) ? a.lightType : 'POINT', ...(typeof a.energy === 'number' && Number.isFinite(a.energy) ? { energy: a.energy } : {}) };
48
+ if (a.type === 'material') {
49
+ return {
50
+ type: a.type,
51
+ name: a.name,
52
+ color: color(a.color),
53
+ ...(typeof a.roughness === 'number' && Number.isFinite(a.roughness) && a.roughness >= 0 && a.roughness <= 1 ? { roughness: a.roughness } : {}),
54
+ ...(typeof a.metallic === 'number' && Number.isFinite(a.metallic) && a.metallic >= 0 && a.metallic <= 1 ? { metallic: a.metallic } : {})
55
+ };
56
+ }
57
+ if (a.type === 'render') {
58
+ if (typeof a.outputPath !== 'string' || !a.outputPath.trim()) throw new Error('render action requires an outputPath');
59
+ return { type: a.type, name: a.name, outputPath: a.outputPath, ...(Array.isArray(a.resolution) && a.resolution.length === 2 && a.resolution.every(n => typeof n === 'number' && n > 0) ? { resolution: [a.resolution[0], a.resolution[1]] } : {}) };
60
+ }
61
+ if (a.type === 'transform') {
62
+ if (a.location === undefined && a.scale === undefined && a.rotation === undefined) throw new Error('transform requires location or scale');
63
+ return { type: a.type, name: a.name, ...(a.location === undefined ? {} : { location: vector(a.location) }), ...(a.scale === undefined ? {} : { scale: vector(a.scale) }), ...(a.rotation === undefined ? {} : { rotation: vector(a.rotation) }) };
64
+ }
65
+ throw new Error('Unsupported batch action');
66
+ });
67
+ return { output: job.output, actions, ...(typeof job.input === 'string' ? { input: job.input } : {}) };
68
+ }
69
+ const inside = (root: string, file: string): boolean => {
70
+ const relative = path.relative(root, file);
71
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
72
+ };
73
+ async function scopedPath(root: string, name: string, existing: boolean): Promise<string> {
74
+ const target = path.resolve(root, name);
75
+ const resolved = existing ? await fs.realpath(target) : path.join(await fs.realpath(path.dirname(target)), path.basename(target));
76
+ if (!inside(root, resolved)) throw new Error('Creative files must stay inside the selected workspace');
77
+ if (!existing) {
78
+ try { await fs.lstat(target); throw new Error('Output already exists; select a new filename'); }
79
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
80
+ }
81
+ return resolved;
82
+ }
83
+ // Data and executable Python are separate files. No model-supplied Python is evaluated.
84
+ const SCRIPT = `import bpy, json, sys
85
+ from pathlib import Path
86
+ job = json.loads(Path(sys.argv[sys.argv.index('--') + 1]).read_text())
87
+ if job.get('input'):
88
+ bpy.ops.wm.open_mainfile(filepath=job['input'], load_ui=False, use_scripts=False)
89
+ else:
90
+ bpy.ops.wm.read_factory_settings(use_empty=True)
91
+ for a in job['actions']:
92
+ t = a['type']
93
+ if t == 'add_cube':
94
+ if bpy.data.objects.get(a['name']):
95
+ raise ValueError('Object already exists: ' + a['name'])
96
+ bpy.ops.mesh.primitive_cube_add(location=a['location'])
97
+ obj = bpy.context.object
98
+ obj.name = a['name']
99
+ obj.scale = a['scale']
100
+ elif t == 'add_sphere':
101
+ if bpy.data.objects.get(a['name']):
102
+ raise ValueError('Object already exists: ' + a['name'])
103
+ bpy.ops.mesh.primitive_uv_sphere_add(location=a.get('location', [0, 0, 0]), radius=a.get('radius', 1.0))
104
+ obj = bpy.context.object
105
+ obj.name = a['name']
106
+ obj.scale = a['scale']
107
+ elif t == 'add_cylinder':
108
+ if bpy.data.objects.get(a['name']):
109
+ raise ValueError('Object already exists: ' + a['name'])
110
+ bpy.ops.mesh.primitive_cylinder_add(location=a.get('location', [0, 0, 0]), radius=a.get('radius', 1.0), depth=a.get('depth', 2.0))
111
+ obj = bpy.context.object
112
+ obj.name = a['name']
113
+ obj.scale = a['scale']
114
+ elif t == 'add_plane':
115
+ if bpy.data.objects.get(a['name']):
116
+ raise ValueError('Object already exists: ' + a['name'])
117
+ bpy.ops.mesh.primitive_plane_add(location=a.get('location', [0, 0, 0]))
118
+ obj = bpy.context.object
119
+ obj.name = a['name']
120
+ obj.scale = a['scale']
121
+ elif t == 'add_camera':
122
+ if bpy.data.objects.get(a['name']):
123
+ raise ValueError('Object already exists: ' + a['name'])
124
+ bpy.ops.object.camera_add(location=a.get('location', [0, -10, 5]))
125
+ cam = bpy.context.object
126
+ cam.name = a['name']
127
+ bpy.context.scene.camera = cam
128
+ elif t == 'add_light':
129
+ if bpy.data.objects.get(a['name']):
130
+ raise ValueError('Object already exists: ' + a['name'])
131
+ ltype = a.get('lightType', 'POINT')
132
+ bpy.ops.object.light_add(type=ltype, location=a.get('location', [4, 1, 6]))
133
+ lt = bpy.context.object
134
+ lt.name = a['name']
135
+ if 'energy' in a and hasattr(lt.data, 'energy'):
136
+ lt.data.energy = a['energy']
137
+ elif t == 'material':
138
+ obj = bpy.data.objects.get(a['name'])
139
+ if obj is None:
140
+ raise ValueError('Object not found: ' + a['name'])
141
+ mat = bpy.data.materials.new(name=a['name'] + '_mat')
142
+ mat.use_nodes = True
143
+ bsdf = mat.node_tree.nodes.get('Principled BSDF')
144
+ if bsdf:
145
+ bsdf.inputs['Base Color'].default_value = a['color']
146
+ if 'roughness' in a and 'Roughness' in bsdf.inputs:
147
+ bsdf.inputs['Roughness'].default_value = a['roughness']
148
+ if 'metallic' in a and 'Metallic' in bsdf.inputs:
149
+ bsdf.inputs['Metallic'].default_value = a['metallic']
150
+ if obj.data and hasattr(obj.data, 'materials'):
151
+ obj.data.materials.append(mat)
152
+ elif t == 'transform':
153
+ obj = bpy.data.objects.get(a['name'])
154
+ if obj is None:
155
+ raise ValueError('Object not found: ' + a['name'])
156
+ if 'location' in a: obj.location = a['location']
157
+ if 'scale' in a: obj.scale = a['scale']
158
+ if 'rotation' in a: obj.rotation_euler = a['rotation']
159
+ elif t == 'render':
160
+ if 'outputPath' in a:
161
+ bpy.context.scene.render.filepath = a['outputPath']
162
+ if 'resolution' in a:
163
+ bpy.context.scene.render.resolution_x = a['resolution'][0]
164
+ bpy.context.scene.render.resolution_y = a['resolution'][1]
165
+ bpy.ops.render.render(write_still=True)
166
+ bpy.context.view_layer.update()
167
+ bpy.context.preferences.filepaths.save_version = 0
168
+ bpy.ops.wm.save_as_mainfile(filepath=job['stagedOutput'], check_existing=False, compress=False)
169
+ scene = [{'name': o.name, 'type': o.type, 'location': list(o.location), 'scale': list(o.scale)} for o in list(bpy.context.scene.objects)[:200]]
170
+ Path(job['result']).write_text(json.dumps({'objects': scene, 'totalObjects': len(bpy.context.scene.objects)}))
171
+ `;
172
+
173
+ export async function runBlenderBatch(value: unknown, workspace = process.cwd(), signal?: AbortSignal) {
174
+ signal?.throwIfAborted();
175
+ const job = parseBatch(value);
176
+ const root = await fs.realpath(workspace);
177
+ const output = await scopedPath(root, job.output, false);
178
+ const input = job.input ? await scopedPath(root, job.input, true) : undefined;
179
+ for (const action of job.actions) {
180
+ if (action.type === 'render') {
181
+ action.outputPath = await scopedPath(root, action.outputPath, false);
182
+ }
183
+ }
184
+ const engine = await findBlender();
185
+ if (!engine) throw new Error('Blender is not installed at a supported location');
186
+ const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'causyne-creative-'));
187
+ const stagedOutput = path.join(path.dirname(output), `.causyne-scene-${randomUUID()}.blend`);
188
+ let reserved = false;
189
+ try {
190
+ const reservation = await fs.open(stagedOutput, 'wx', 0o600);
191
+ await reservation.close();
192
+ reserved = true;
193
+ const script = path.join(temp, 'batch.py');
194
+ const request = path.join(temp, 'job.json');
195
+ const resultFile = path.join(temp, 'result.json');
196
+ await fs.writeFile(script, SCRIPT, { mode: 0o600 });
197
+ await fs.writeFile(request, JSON.stringify({ ...job, input, stagedOutput, result: resultFile }), { mode: 0o600 });
198
+ const env = Object.fromEntries(['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'TEMP', 'TMP', 'TMPDIR', 'LANG'].flatMap(key => process.env[key] ? [[key, process.env[key]!]] : []));
199
+ await exec(engine.executable, ['--background', '--factory-startup', '--disable-autoexec', '--python-exit-code', '1', '--python', script, '--', request], {
200
+ cwd: root, timeout: 120000, maxBuffer: 1024 * 1024, windowsHide: true, env, ...(signal ? { signal } : {})
201
+ });
202
+ const summary: unknown = JSON.parse(await fs.readFile(resultFile, 'utf8'));
203
+ const handle = await fs.open(stagedOutput, 'r');
204
+ try {
205
+ const header = Buffer.alloc(7); await handle.read(header, 0, 7, 0);
206
+ if (header.toString() !== 'BLENDER') throw new Error('Blender did not produce a valid .blend header');
207
+ } finally { await handle.close(); }
208
+ // Recheck scope after the process returns; never overwrite an existing project.
209
+ signal?.throwIfAborted();
210
+ await scopedPath(root, output, false);
211
+ await fs.copyFile(stagedOutput, output, fs.constants.COPYFILE_EXCL);
212
+ return { success: true, output, version: engine.version, summary };
213
+ } finally {
214
+ if (reserved) await fs.rm(stagedOutput, { force: true });
215
+ await fs.rm(temp, { recursive: true, force: true });
216
+ }
217
+ }
@@ -0,0 +1,56 @@
1
+ import { runBlenderBatch } from './blenderBatch.js';
2
+ import { runStudioEdit } from './studioScripts.js';
3
+ export function validatePipeline(value) {
4
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
5
+ throw new Error('Pipeline must be an object');
6
+ }
7
+ const obj = value;
8
+ if (!Array.isArray(obj.stages) || obj.stages.length < 1 || obj.stages.length > 16) {
9
+ throw new Error('Provide 1..16 pipeline stages');
10
+ }
11
+ const stages = obj.stages.map((raw, index) => {
12
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
13
+ throw new Error(`Stage ${index + 1} must be an object`);
14
+ }
15
+ const s = raw;
16
+ const id = typeof s.id === 'string' && s.id.trim() ? s.id.trim() : `stage_${index + 1}`;
17
+ if (s.type !== '3d_batch' && s.type !== 'studio_batch') {
18
+ throw new Error(`Stage "${id}" must have type "3d_batch" or "studio_batch"`);
19
+ }
20
+ const type = s.type;
21
+ if (!s.config || typeof s.config !== 'object' || Array.isArray(s.config)) {
22
+ throw new Error(`Stage "${id}" requires a configuration object`);
23
+ }
24
+ return { id, type, config: s.config };
25
+ });
26
+ return { stages };
27
+ }
28
+ export async function runStudioPipeline(value, workspace = process.cwd(), signal) {
29
+ signal?.throwIfAborted();
30
+ const pipeline = validatePipeline(value);
31
+ const stageResults = [];
32
+ for (let i = 0; i < pipeline.stages.length; i++) {
33
+ signal?.throwIfAborted();
34
+ const stage = pipeline.stages[i];
35
+ try {
36
+ if (stage.type === '3d_batch') {
37
+ const res = await runBlenderBatch(stage.config, workspace, signal);
38
+ stageResults.push({ id: stage.id, success: true, output: res.output });
39
+ }
40
+ else {
41
+ const res = await runStudioEdit(stage.config, workspace, signal);
42
+ if (!res.success) {
43
+ stageResults.push({ id: stage.id, success: false, error: res.error });
44
+ return { success: false, stagesCompleted: i, stageResults, error: `Stage "${stage.id}" failed: ${res.error}` };
45
+ }
46
+ stageResults.push({ id: stage.id, success: true, output: res.output });
47
+ }
48
+ }
49
+ catch (err) {
50
+ const errMsg = err instanceof Error ? err.message : String(err);
51
+ stageResults.push({ id: stage.id, success: false, error: errMsg });
52
+ return { success: false, stagesCompleted: i, stageResults, error: `Stage "${stage.id}" failed: ${errMsg}` };
53
+ }
54
+ }
55
+ return { success: true, stagesCompleted: pipeline.stages.length, stageResults };
56
+ }