@next2012/aistudio-blender 0.0.0-stage → 0.2.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/CHANGELOG.md +28 -0
- package/README.md +55 -2
- package/cordis.patch.yml +3 -0
- package/lib/assets/bootstrap.py +57 -0
- package/lib/assets/publish.py +47 -0
- package/lib/assets/render.py +123 -0
- package/lib/index.js +605 -0
- package/lib/types/glb.d.ts +2 -0
- package/lib/types/index.d.ts +3 -0
- package/lib/types/worker.d.ts +10 -0
- package/package.json +58 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @next2012/aistudio-blender
|
|
2
|
+
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 补齐 Linux ARM64 的 Blender 运行资源与依赖安装;构建检查 ELF 架构,按架构隔离图形组件缓存,打包及离线安装检查按目标平台选择分片。
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- @next2012/aistudio-blender-runtime@0.2.1
|
|
10
|
+
|
|
11
|
+
## 0.2.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- d5815bb: OpenDesign 增加参考照片新建与导入基模精修两种 3D 任务入口,支持产品 ID 与电商展示用途、参考图分类和尺寸说明。
|
|
16
|
+
|
|
17
|
+
新增独立 Blender MCP 执行、GLB 模型查看和平台运行包;模型源工程与预览共同保存版本,支持选零件、截图标注、模型恢复、PNG/MP4 渲染及限定资源的模型分享。回合结束等待工具与进程清理后释放项目写锁,运行环境通过 npm 分片离线初始化。
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- Updated dependencies [d5815bb]
|
|
22
|
+
- Updated dependencies [d5815bb]
|
|
23
|
+
- @next2012/aistudio-sdk@0.6.0
|
|
24
|
+
- @next2012/aistudio-blender-runtime@0.2.0
|
|
25
|
+
|
|
26
|
+
## 0.1.0
|
|
27
|
+
|
|
28
|
+
增加独立 Blender MCP 建模、基模导入、模型版本交付和三维渲染。
|
package/README.md
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @next2012/aistudio-blender
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenDesign 的 Blender 执行插件。配合模型查看插件提供“参考照片新建”和“导入模型精修”两个入口,适用于电子产品 ID 设计、电商产品、玩具和道具展示。
|
|
4
|
+
|
|
5
|
+
每次操作独立启动 Blender 和原版 Blender MCP,从当前源工程或指定版本继续。进程与端口独立;停止、超时和插件卸载时等待 Blender 和显示进程结束,就绪标记校验本次实例以避免端口冲突误连。执行结果先保存在私有临时目录,检查后使用内容摘要发布项目文件;失败保留原版本。
|
|
6
|
+
|
|
7
|
+
服务 `studioBlender` 提供 `status`、`edit` 和 `render`。`aistudio_blender` 接受项目相对 GLB 路径、`bpy` 脚本、可选 `attachmentId` / `sourcePath` / `bundleId`。`aistudio_model_info` 返回零件 ID、源尺寸、源网格面数、UV 和材质摘要;建模脚本的打印结果和实际缩略图同时返回 AI。项目图片用 `assets` 数组传入,脚本以 `ASSETS["assets/logo.png"]` 读取;交付前打包贴图。
|
|
8
|
+
|
|
9
|
+
MCP 脚本在工作台的受信任进程中执行,Blender 的 Python 不自动继承远端 Codex 沙箱。插件按当前轮权限拒绝只读修改,临时目录隔离用于场景和文件交付;它不构成操作系统权限隔离。
|
|
10
|
+
|
|
11
|
+
## 使用
|
|
12
|
+
|
|
13
|
+
安装与当前 SDK、宿主匹配的两个业务插件:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
aistudio plugin add @next2012/aistudio-blender@0.2.0
|
|
17
|
+
aistudio plugin add @next2012/aistudio-model-viewer@0.2.0
|
|
18
|
+
aistudio doctor --repair
|
|
19
|
+
aistudio web
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
这两个插件为可选安装,不进入原有默认组合。停止工作台后安装或升级;首次安装需先将主程序升级至 0.5.0,并执行 setup 同步依赖插件。
|
|
23
|
+
|
|
24
|
+
私有运行包按平台从 npm 安装。首版资源仅实现 Linux x64、glibc 2.35 及以上;其他平台尚未验证。已有部署可明确设置三个绝对路径:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
BLENDER_PATH=/Blender目录/blender
|
|
28
|
+
BLENDER_MCP_PYTHON=/MCP虚拟环境/bin/python
|
|
29
|
+
BLENDER_MCP_ADDON=/固定版本/addon.py
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
可用 `BLENDER_XVFB_PATH` 指定已有 Xvfb。已有环境需自行具备显示与图形依赖;插件不会连接用户桌面或已有的共享 MCP 端口。
|
|
33
|
+
|
|
34
|
+
## 交付与渲染
|
|
35
|
+
|
|
36
|
+
每个模型版本固定 `.blend`、`.glb`、缩略图及零件信息。网页模型内嵌贴图;源工程打包图片。模型可整体重命名并同步源工程和预览;外部修改后需明确选择工程重新导入,避免工程与预览不同步。公开 GLB 仅保留零件标识,不分发工程自定义属性。模型或基模 ZIP 上限 128 MiB,完整模型版本同样不超过 128 MiB。支持 GLB、FBX、OBJ、BLEND;ZIP 可包含一个基模和贴图,拒绝越界路径、未知文件和过大解压内容。
|
|
37
|
+
|
|
38
|
+
渲染通过已有 `studio-export` 调度,支持 PNG、透明背景 PNG、MP4 环绕和整体/细节镜头,提供 Cycles 与 EEVEE。Cycles 可自动选择可用 GPU,也可通过导出接口明确指定 cpu/gpu;GPU 驱动由宿主提供,资源包包含软件图形基线。正式视频逐帧渲染后由 FFmpeg 编码。渲染操作读取固定源版本,不改写建模工程。
|
|
39
|
+
|
|
40
|
+
使用 `aistudio_artifact_copy` 将已渲染原件复制到项目 assets,继续用现有 HTML/HyperFrames 视频流程合成卖点、字幕和已有音轨。用户提供的 Logo 和照片也可通过该工具从附件复制为素材。
|
|
41
|
+
|
|
42
|
+
照片入口通过参考图与尺寸驱动建模,不提供摄影测量;尺寸不足时需说明估算。Tripo 基模通过导出后上传,当前不调用 Tripo 云端 API。
|
|
43
|
+
|
|
44
|
+
浏览器交互预览使用 GLB。同一模型版本中的 BLEND 保留完整编辑工程;独立 BLEND 文件需通过导入流程生成 GLB,直接点击 BLEND 自动转换预览尚未实现。
|
|
45
|
+
|
|
46
|
+
## 开发验收
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pnpm build
|
|
50
|
+
pnpm test
|
|
51
|
+
pnpm test:blender:live
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
本机资源构建或明确配置已有环境后,可用 `AISTUDIO_BLENDER=1 pnpm dev` 为开发组合加入两个插件;已有插件选择继续保留。
|
|
55
|
+
|
|
56
|
+
真实验收使用独立实例,覆盖建模、连续修改、模型导入、取消、历史恢复、PNG/MP4、桌面/手机查看和分享。截图保存在 `test-results/blender-model-*.png`。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""在独立 Blender 窗口内启动原版 MCP 插件;配置只来自宿主临时目录。"""
|
|
2
|
+
import bpy
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
def bootstrap():
|
|
9
|
+
config = json.loads(Path(sys.argv[sys.argv.index('--') + 1]).read_text())
|
|
10
|
+
if config.get('libraries'):
|
|
11
|
+
sys.path.insert(0, config['libraries'])
|
|
12
|
+
source = config.get('source')
|
|
13
|
+
if source:
|
|
14
|
+
suffix = Path(source).suffix.lower()
|
|
15
|
+
if suffix == '.blend':
|
|
16
|
+
bpy.ops.wm.open_mainfile(filepath=source, load_ui=False, use_scripts=False)
|
|
17
|
+
else:
|
|
18
|
+
bpy.ops.object.select_all(action='SELECT')
|
|
19
|
+
bpy.ops.object.delete(use_global=False)
|
|
20
|
+
if suffix in ('.glb', '.gltf'):
|
|
21
|
+
bpy.ops.import_scene.gltf(filepath=source)
|
|
22
|
+
elif suffix == '.fbx':
|
|
23
|
+
bpy.ops.import_scene.fbx(filepath=source)
|
|
24
|
+
elif suffix == '.obj':
|
|
25
|
+
bpy.ops.wm.obj_import(filepath=source)
|
|
26
|
+
else:
|
|
27
|
+
raise ValueError('不支持这个基模格式')
|
|
28
|
+
else:
|
|
29
|
+
bpy.ops.object.select_all(action='SELECT')
|
|
30
|
+
bpy.ops.object.delete(use_global=False)
|
|
31
|
+
|
|
32
|
+
spec = importlib.util.spec_from_file_location('aistudio_blender_mcp', config['addon'])
|
|
33
|
+
addon = importlib.util.module_from_spec(spec)
|
|
34
|
+
sys.modules[spec.name] = addon
|
|
35
|
+
spec.loader.exec_module(addon)
|
|
36
|
+
addon.register()
|
|
37
|
+
bpy.context.scene.blendermcp_port = config['port']
|
|
38
|
+
bpy.context.scene.blendermcp_auto_start_server = False
|
|
39
|
+
server = addon.BlenderMCPServer(host='127.0.0.1', port=config['port'])
|
|
40
|
+
bpy.types.blendermcp_server = server
|
|
41
|
+
server.start()
|
|
42
|
+
if not server.running or server.socket is None or server.socket.getsockname()[1] != config['port']:
|
|
43
|
+
raise RuntimeError('本次 Blender MCP 端口启动失败')
|
|
44
|
+
Path(config['readyPath']).write_text(config['nonce'])
|
|
45
|
+
print('AISTUDIO_BLENDER_READY', flush=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
bootstrap()
|
|
50
|
+
except Exception:
|
|
51
|
+
# 图形模式脚本失败时 Blender 仍会停留在窗口中;启动失败必须明确结束进程。
|
|
52
|
+
import os
|
|
53
|
+
import traceback
|
|
54
|
+
traceback.print_exc()
|
|
55
|
+
sys.stdout.flush()
|
|
56
|
+
sys.stderr.flush()
|
|
57
|
+
os._exit(1)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""保存可继续编辑的工程、浏览器模型和与实际场景一致的零件信息。"""
|
|
2
|
+
import bpy
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
out = Path(OUTPUT_DIRECTORY)
|
|
8
|
+
scene = bpy.context.scene
|
|
9
|
+
objects = [obj for obj in scene.objects if obj.type == 'MESH']
|
|
10
|
+
if not objects:
|
|
11
|
+
raise ValueError('场景没有可交付的网格')
|
|
12
|
+
if len(objects) > 4000:
|
|
13
|
+
raise ValueError('模型零件超过 4000 个,请合并不需要独立编辑的零件')
|
|
14
|
+
seen = set()
|
|
15
|
+
for obj in objects:
|
|
16
|
+
identity = obj.get('aistudio_id')
|
|
17
|
+
# Blender 复制物体时也复制自定义属性,交付前需要重新分配重复的标识。
|
|
18
|
+
try:
|
|
19
|
+
valid = isinstance(identity, str) and str(uuid.UUID(identity)) == identity and uuid.UUID(identity).version == 4
|
|
20
|
+
except (ValueError, TypeError, AttributeError):
|
|
21
|
+
valid = False
|
|
22
|
+
if not valid or identity in seen:
|
|
23
|
+
identity = str(uuid.uuid4())
|
|
24
|
+
obj['aistudio_id'] = identity
|
|
25
|
+
seen.add(identity)
|
|
26
|
+
bpy.ops.file.pack_all()
|
|
27
|
+
for image in bpy.data.images:
|
|
28
|
+
if image.packed_file:
|
|
29
|
+
image.filepath = '//textures/' + (Path(image.filepath).name or image.name)
|
|
30
|
+
# 交付工程不保存本次实例的临时端口;当前进程仍使用原连接完成导出。
|
|
31
|
+
port = scene.blendermcp_port
|
|
32
|
+
scene.blendermcp_port = 9876
|
|
33
|
+
bpy.ops.wm.save_as_mainfile(filepath=str(out / 'model.blend'), check_existing=False)
|
|
34
|
+
scene.blendermcp_port = port
|
|
35
|
+
bpy.ops.export_scene.gltf(filepath=str(out / 'model.glb'), export_format='GLB',
|
|
36
|
+
export_extras=True, export_cameras=False, export_lights=False,
|
|
37
|
+
use_renderable=True, export_animations=False, export_current_frame=True)
|
|
38
|
+
info = {
|
|
39
|
+
'units': 'm',
|
|
40
|
+
'objects': [{'id': obj['aistudio_id'], 'name': obj.name,
|
|
41
|
+
'dimensions': [value * scene.unit_settings.scale_length for value in obj.dimensions],
|
|
42
|
+
'vertexCount': len(obj.data.vertices), 'faceCount': len(obj.data.polygons),
|
|
43
|
+
'uv': len(obj.data.uv_layers) > 0,
|
|
44
|
+
'materials': [slot.material.name for slot in obj.material_slots if slot.material][:32]}
|
|
45
|
+
for obj in objects],
|
|
46
|
+
}
|
|
47
|
+
(out / 'model.json').write_text(json.dumps(info))
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""从固定模型版本创建展示场景,输出图片或可恢复的逐帧镜头。"""
|
|
2
|
+
import bpy
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import sys
|
|
6
|
+
from mathutils import Vector
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
config = json.loads(Path(sys.argv[sys.argv.index('--') + 1]).read_text())
|
|
10
|
+
bpy.ops.wm.open_mainfile(filepath=config['source'], load_ui=False, use_scripts=False)
|
|
11
|
+
scene = bpy.context.scene
|
|
12
|
+
hidden = set((config.get('view') or {}).get('hidden', []))
|
|
13
|
+
for obj in scene.objects:
|
|
14
|
+
if obj.get('aistudio_id') in hidden:
|
|
15
|
+
obj.hide_render = True
|
|
16
|
+
objects = [o for o in scene.objects if o.type == 'MESH' and not o.hide_render]
|
|
17
|
+
if not objects:
|
|
18
|
+
raise ValueError('模型没有可渲染网格')
|
|
19
|
+
points = [o.matrix_world @ Vector(corner) for o in objects for corner in o.bound_box]
|
|
20
|
+
low = Vector(tuple(min(p[i] for p in points) for i in range(3)))
|
|
21
|
+
high = Vector(tuple(max(p[i] for p in points) for i in range(3)))
|
|
22
|
+
center = (low + high) / 2
|
|
23
|
+
radius = max((high - low).length / 2, 0.01)
|
|
24
|
+
# 展示灯光与相机只写入临时场景,原始商品工程保持可继续编辑。
|
|
25
|
+
for obj in list(scene.objects):
|
|
26
|
+
if obj.type in ('LIGHT', 'CAMERA'):
|
|
27
|
+
bpy.data.objects.remove(obj, do_unlink=True)
|
|
28
|
+
scene.world = bpy.data.worlds.new('展示环境')
|
|
29
|
+
scene.world.use_nodes = True
|
|
30
|
+
scene.world.node_tree.nodes['Background'].inputs[0].default_value = (0.72, 0.75, 0.8, 1)
|
|
31
|
+
scene.world.node_tree.nodes['Background'].inputs[1].default_value = 0.5
|
|
32
|
+
for name, offset, power, size in [('主光', (3, -4, 5), 650, 4), ('补光', (-3, -1, 2), 350, 3), ('轮廓光', (1, 4, 4), 800, 2)]:
|
|
33
|
+
data = bpy.data.lights.new(name, 'AREA')
|
|
34
|
+
data.energy = power * radius * radius
|
|
35
|
+
data.shape = 'DISK'
|
|
36
|
+
data.size = size * radius
|
|
37
|
+
light = bpy.data.objects.new(name, data)
|
|
38
|
+
scene.collection.objects.link(light)
|
|
39
|
+
light.location = center + Vector(offset) * radius
|
|
40
|
+
light.rotation_euler = (center - light.location).to_track_quat('-Z', 'Y').to_euler()
|
|
41
|
+
camera = bpy.data.objects.new('商品展示相机', bpy.data.cameras.new('商品展示相机'))
|
|
42
|
+
scene.collection.objects.link(camera)
|
|
43
|
+
scene.camera = camera
|
|
44
|
+
camera.data.lens = 50
|
|
45
|
+
camera.data.clip_start = radius / 1000
|
|
46
|
+
camera.data.clip_end = radius * 1000
|
|
47
|
+
scene.render.engine = 'CYCLES' if config.get('engine', 'cycles') == 'cycles' else 'BLENDER_EEVEE_NEXT'
|
|
48
|
+
if scene.render.engine == 'CYCLES':
|
|
49
|
+
scene.cycles.samples = config.get('samples', 32)
|
|
50
|
+
scene.cycles.device = 'CPU'
|
|
51
|
+
requested_device = config.get('device', 'auto')
|
|
52
|
+
gpu_found = False
|
|
53
|
+
if requested_device != 'cpu':
|
|
54
|
+
preferences = bpy.context.preferences.addons['cycles'].preferences
|
|
55
|
+
for backend in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI', 'METAL'):
|
|
56
|
+
try:
|
|
57
|
+
preferences.compute_device_type = backend
|
|
58
|
+
preferences.refresh_devices()
|
|
59
|
+
available = [device for device in preferences.devices if device.type != 'CPU']
|
|
60
|
+
if available:
|
|
61
|
+
for device in preferences.devices:
|
|
62
|
+
device.use = device.type != 'CPU'
|
|
63
|
+
scene.cycles.device = 'GPU'
|
|
64
|
+
gpu_found = True
|
|
65
|
+
break
|
|
66
|
+
except (TypeError, RuntimeError):
|
|
67
|
+
continue
|
|
68
|
+
if requested_device == 'gpu' and not gpu_found:
|
|
69
|
+
raise RuntimeError('未检测到可用的 Cycles GPU,请选择 CPU 或检查宿主驱动')
|
|
70
|
+
scene.cycles.use_denoising = True
|
|
71
|
+
scene.render.resolution_x = config['width']
|
|
72
|
+
scene.render.resolution_y = config['height']
|
|
73
|
+
scene.render.resolution_percentage = 100
|
|
74
|
+
# 白底棚拍使用独立地面,透明输出仅保留商品本身。
|
|
75
|
+
if not config.get('transparent', False):
|
|
76
|
+
bpy.ops.mesh.primitive_plane_add(size=radius * 20, location=(center.x, center.y, low.z - radius * 0.005))
|
|
77
|
+
floor = bpy.context.object
|
|
78
|
+
floor.name = '展示地面'
|
|
79
|
+
material = bpy.data.materials.new('白色展示背景')
|
|
80
|
+
material.diffuse_color = (0.92, 0.92, 0.92, 1)
|
|
81
|
+
floor.data.materials.append(material)
|
|
82
|
+
scene.render.image_settings.file_format = 'PNG'
|
|
83
|
+
scene.render.image_settings.color_mode = 'RGBA'
|
|
84
|
+
scene.render.image_settings.color_depth = '8'
|
|
85
|
+
scene.render.film_transparent = config.get('transparent', False)
|
|
86
|
+
source_fps = scene.render.fps / scene.render.fps_base
|
|
87
|
+
scene.render.fps = config.get('fps', 24)
|
|
88
|
+
frames = config.get('frames', 1)
|
|
89
|
+
animation = config.get('animation', False)
|
|
90
|
+
out = Path(config['directory'])
|
|
91
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
for frame in range(frames):
|
|
93
|
+
if animation:
|
|
94
|
+
sample = scene.frame_start + frame * source_fps / scene.render.fps
|
|
95
|
+
scene.frame_set(int(sample), subframe=sample - int(sample))
|
|
96
|
+
theta = -math.pi / 3 + (2 * math.pi * frame / frames if frames > 1 else 0)
|
|
97
|
+
distance = 3.5 * radius
|
|
98
|
+
target = center
|
|
99
|
+
if config.get('preset') == 'features' and frames > 1:
|
|
100
|
+
# 固定模板包含整体、细节和收尾三个镜头,逐帧位置由同一模型计算。
|
|
101
|
+
progress = frame / frames
|
|
102
|
+
distance *= 0.65 if 0.35 <= progress < 0.7 else 1
|
|
103
|
+
theta = -math.pi / 3 + progress * math.pi
|
|
104
|
+
if config.get('view'):
|
|
105
|
+
view = config['view']
|
|
106
|
+
scale = scene.unit_settings.scale_length
|
|
107
|
+
# glTF/浏览器使用 Y 向上,Blender 使用 Z 向上,位置同时换算回场景单位。
|
|
108
|
+
to_blender = lambda value: Vector((value[0], -value[2], value[1])) / scale
|
|
109
|
+
target = to_blender(view['target'])
|
|
110
|
+
offset = to_blender(view['camera']) - target
|
|
111
|
+
angle = 2 * math.pi * frame / frames if animation and frames > 1 else 0
|
|
112
|
+
camera.location = target + Vector((offset.x * math.cos(angle) - offset.y * math.sin(angle),
|
|
113
|
+
offset.x * math.sin(angle) + offset.y * math.cos(angle), offset.z))
|
|
114
|
+
camera.data.sensor_fit = 'VERTICAL'
|
|
115
|
+
camera.data.lens = camera.data.sensor_height / (2 * math.tan(math.radians(view.get('fov', 40)) / 2))
|
|
116
|
+
else:
|
|
117
|
+
camera.location = center + Vector((math.cos(theta) * distance, math.sin(theta) * distance, 1.5 * radius))
|
|
118
|
+
camera.rotation_euler = (target - camera.location).to_track_quat('-Z', 'Y').to_euler()
|
|
119
|
+
scene.render.filepath = str(out / ('image.png' if not animation else f'frame-{frame:06d}.png'))
|
|
120
|
+
# 已完成的帧可在同一任务重试时复用。
|
|
121
|
+
if not Path(scene.render.filepath).exists():
|
|
122
|
+
bpy.ops.render.render(write_still=True)
|
|
123
|
+
print(f'AISTUDIO_FRAME {frame + 1} {frames}', flush=True)
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
// lib/types/index.js
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdtemp, mkdir, readFile as readFile2, stat, rm, writeFile as writeFile2 } from "node:fs/promises";
|
|
4
|
+
import { dirname, extname, posix, resolve as resolve2 } from "node:path";
|
|
5
|
+
import { unzipSync } from "fflate";
|
|
6
|
+
import { api, safePath, StudioError } from "@next2012/aistudio-sdk/node";
|
|
7
|
+
import { blenderEnvironment as blenderEnvironment2, blenderStatus } from "@next2012/aistudio-blender-runtime";
|
|
8
|
+
|
|
9
|
+
// lib/types/glb.js
|
|
10
|
+
function previewGlb(bytes, objectIds) {
|
|
11
|
+
if (bytes.length < 20 || bytes.toString("ascii", 0, 4) !== "glTF" || bytes.readUInt32LE(4) !== 2 || bytes.readUInt32LE(8) !== bytes.length || bytes.toString("ascii", 16, 20) !== "JSON")
|
|
12
|
+
throw new Error("Blender \u672A\u5BFC\u51FA\u6709\u6548 GLB");
|
|
13
|
+
const length = bytes.readUInt32LE(12);
|
|
14
|
+
if (20 + length > bytes.length)
|
|
15
|
+
throw new Error("GLB JSON \u957F\u5EA6\u65E0\u6548");
|
|
16
|
+
const document = JSON.parse(bytes.toString("utf8", 20, 20 + length));
|
|
17
|
+
const identities = (document.nodes ?? []).map((node) => node.extras?.aistudio_id);
|
|
18
|
+
const clean = (value) => {
|
|
19
|
+
if (!value || typeof value !== "object")
|
|
20
|
+
return;
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
for (const item of value)
|
|
23
|
+
clean(item);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const record = value;
|
|
27
|
+
delete record.extras;
|
|
28
|
+
for (const item of Object.values(record))
|
|
29
|
+
clean(item);
|
|
30
|
+
};
|
|
31
|
+
clean(document);
|
|
32
|
+
for (const [index, identity] of identities.entries())
|
|
33
|
+
if (typeof identity === "string" && objectIds.has(identity))
|
|
34
|
+
document.nodes[index].extras = { aistudio_id: identity };
|
|
35
|
+
const json = Buffer.from(JSON.stringify(document)), padded = Buffer.alloc(Math.ceil(json.length / 4) * 4, 32);
|
|
36
|
+
json.copy(padded);
|
|
37
|
+
const remaining = bytes.subarray(20 + length), header = Buffer.from(bytes.subarray(0, 12)), chunk = Buffer.alloc(8);
|
|
38
|
+
header.writeUInt32LE(20 + padded.length + remaining.length, 8);
|
|
39
|
+
chunk.writeUInt32LE(padded.length, 0);
|
|
40
|
+
chunk.write("JSON", 4, "ascii");
|
|
41
|
+
return Buffer.concat([header, chunk, padded, remaining]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// lib/types/worker.js
|
|
45
|
+
import { spawn } from "node:child_process";
|
|
46
|
+
import { createServer } from "node:net";
|
|
47
|
+
import { randomUUID } from "node:crypto";
|
|
48
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
49
|
+
import { writeFile, readFile } from "node:fs/promises";
|
|
50
|
+
import { resolve } from "node:path";
|
|
51
|
+
import { fileURLToPath } from "node:url";
|
|
52
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
53
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
54
|
+
import { blenderEnvironment } from "@next2012/aistudio-blender-runtime";
|
|
55
|
+
var asset = (name) => fileURLToPath(new URL("./assets/" + name, import.meta.url));
|
|
56
|
+
function processRun(command, args, env, signal, onLine) {
|
|
57
|
+
signal?.throwIfAborted();
|
|
58
|
+
const child = spawn(command, args, {
|
|
59
|
+
env,
|
|
60
|
+
detached: process.platform !== "win32",
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
62
|
+
});
|
|
63
|
+
let output = "", ended = false, force;
|
|
64
|
+
const terminate = () => {
|
|
65
|
+
if (!child.pid || ended)
|
|
66
|
+
return;
|
|
67
|
+
const kill = (value) => {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(-child.pid, value);
|
|
70
|
+
} catch {
|
|
71
|
+
child.kill(value);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
kill("SIGTERM");
|
|
75
|
+
force ??= setTimeout(() => kill("SIGKILL"), 1500);
|
|
76
|
+
};
|
|
77
|
+
const done = new Promise((accept, reject) => {
|
|
78
|
+
const read = (bytes) => {
|
|
79
|
+
const text = bytes.toString();
|
|
80
|
+
output = (output + text).slice(-12e3);
|
|
81
|
+
onLine?.(text);
|
|
82
|
+
};
|
|
83
|
+
child.stdout.on("data", read);
|
|
84
|
+
child.stderr.on("data", read);
|
|
85
|
+
child.once("error", reject);
|
|
86
|
+
child.once("close", (code) => {
|
|
87
|
+
ended = true;
|
|
88
|
+
clearTimeout(force);
|
|
89
|
+
signal?.removeEventListener("abort", terminate);
|
|
90
|
+
signal?.aborted ? reject(signal.reason) : code === 0 ? accept() : reject(new Error("Blender \u8FDB\u7A0B\u5931\u8D25\uFF1A" + output.slice(-3e3)));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
void done.catch(() => {
|
|
94
|
+
});
|
|
95
|
+
signal?.addEventListener("abort", terminate, { once: true });
|
|
96
|
+
if (signal?.aborted)
|
|
97
|
+
terminate();
|
|
98
|
+
return {
|
|
99
|
+
done,
|
|
100
|
+
get output() {
|
|
101
|
+
return output;
|
|
102
|
+
},
|
|
103
|
+
async stop() {
|
|
104
|
+
terminate();
|
|
105
|
+
await done.catch(() => {
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
async function availablePort() {
|
|
111
|
+
const server = createServer();
|
|
112
|
+
await new Promise((accept, reject) => {
|
|
113
|
+
server.once("error", reject);
|
|
114
|
+
server.listen(0, "127.0.0.1", accept);
|
|
115
|
+
});
|
|
116
|
+
const port = server.address().port;
|
|
117
|
+
await new Promise((accept) => server.close(() => accept()));
|
|
118
|
+
return port;
|
|
119
|
+
}
|
|
120
|
+
async function startWorker(directory, source, signal) {
|
|
121
|
+
const environment = blenderEnvironment(), port = await availablePort();
|
|
122
|
+
const config = resolve(directory, "bootstrap.json"), readyPath = resolve(directory, "ready"), nonce = randomUUID();
|
|
123
|
+
await writeFile(config, JSON.stringify({
|
|
124
|
+
source,
|
|
125
|
+
port,
|
|
126
|
+
addon: environment.addon,
|
|
127
|
+
libraries: environment.libraries,
|
|
128
|
+
readyPath,
|
|
129
|
+
nonce
|
|
130
|
+
}));
|
|
131
|
+
const env = {
|
|
132
|
+
...environment.env,
|
|
133
|
+
BLENDER_USER_RESOURCES: resolve(directory, "config"),
|
|
134
|
+
BLENDER_MCP_DISABLE_TELEMETRY: "1",
|
|
135
|
+
BLENDER_HOST: "127.0.0.1",
|
|
136
|
+
BLENDER_PORT: String(port),
|
|
137
|
+
BLENDER_MCP_SAFE_MODE: "0"
|
|
138
|
+
};
|
|
139
|
+
let displayNumber, displayText = "";
|
|
140
|
+
const display = processRun(environment.xvfb, [
|
|
141
|
+
"-displayfd",
|
|
142
|
+
"1",
|
|
143
|
+
"-screen",
|
|
144
|
+
"0",
|
|
145
|
+
"1280x800x24",
|
|
146
|
+
"-nolisten",
|
|
147
|
+
"tcp",
|
|
148
|
+
"-ac",
|
|
149
|
+
"-noreset",
|
|
150
|
+
...env.XKB_CONFIG_ROOT ? ["-xkbdir", env.XKB_CONFIG_ROOT] : []
|
|
151
|
+
], env, signal, (text) => {
|
|
152
|
+
displayText = (displayText + text).slice(-2e3);
|
|
153
|
+
displayNumber ??= displayText.match(/(?:^|\n)(\d+)\n/)?.[1];
|
|
154
|
+
});
|
|
155
|
+
let worker;
|
|
156
|
+
let client;
|
|
157
|
+
let closing;
|
|
158
|
+
const abort = () => {
|
|
159
|
+
void close();
|
|
160
|
+
};
|
|
161
|
+
const close = () => closing ??= (async () => {
|
|
162
|
+
signal.removeEventListener("abort", abort);
|
|
163
|
+
await client?.close().catch(() => {
|
|
164
|
+
});
|
|
165
|
+
await worker?.stop();
|
|
166
|
+
await display.stop();
|
|
167
|
+
})();
|
|
168
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
169
|
+
try {
|
|
170
|
+
const displayDeadline = Date.now() + 15e3;
|
|
171
|
+
while (displayNumber === void 0) {
|
|
172
|
+
signal.throwIfAborted();
|
|
173
|
+
if (Date.now() > displayDeadline)
|
|
174
|
+
throw new Error("\u865A\u62DF\u663E\u793A\u542F\u52A8\u8D85\u65F6\uFF1A" + display.output.slice(-2e3));
|
|
175
|
+
await Promise.race([
|
|
176
|
+
delay(50, void 0, { signal }),
|
|
177
|
+
display.done.then(() => {
|
|
178
|
+
throw new Error("\u865A\u62DF\u663E\u793A\u63D0\u524D\u9000\u51FA");
|
|
179
|
+
})
|
|
180
|
+
]);
|
|
181
|
+
}
|
|
182
|
+
env.DISPLAY = ":" + displayNumber;
|
|
183
|
+
worker = processRun(environment.binary, [
|
|
184
|
+
"--factory-startup",
|
|
185
|
+
"--disable-autoexec",
|
|
186
|
+
"--python",
|
|
187
|
+
asset("bootstrap.py"),
|
|
188
|
+
"--",
|
|
189
|
+
config
|
|
190
|
+
], env, signal);
|
|
191
|
+
const deadline = Date.now() + 45e3;
|
|
192
|
+
while (true) {
|
|
193
|
+
signal.throwIfAborted();
|
|
194
|
+
const ready = await readFile(readyPath, "utf8").catch((error) => {
|
|
195
|
+
if (error.code === "ENOENT")
|
|
196
|
+
return "";
|
|
197
|
+
throw error;
|
|
198
|
+
});
|
|
199
|
+
if (ready === nonce)
|
|
200
|
+
break;
|
|
201
|
+
if (Date.now() > deadline)
|
|
202
|
+
throw new Error("Blender MCP \u542F\u52A8\u8D85\u65F6\uFF1A" + worker.output.slice(-2e3));
|
|
203
|
+
await Promise.race([
|
|
204
|
+
delay(100, void 0, { signal }),
|
|
205
|
+
worker.done.then(() => {
|
|
206
|
+
throw new Error("Blender \u63D0\u524D\u9000\u51FA");
|
|
207
|
+
})
|
|
208
|
+
]);
|
|
209
|
+
}
|
|
210
|
+
client = new Client({ name: "aistudio-blender", version: "0.1.0" });
|
|
211
|
+
await client.connect(new StdioClientTransport({
|
|
212
|
+
command: environment.python,
|
|
213
|
+
args: ["-m", "blender_mcp.server"],
|
|
214
|
+
env: Object.fromEntries(Object.entries(env).filter((entry) => typeof entry[1] === "string")),
|
|
215
|
+
stderr: "pipe"
|
|
216
|
+
}));
|
|
217
|
+
return {
|
|
218
|
+
close,
|
|
219
|
+
async execute(code, prompt) {
|
|
220
|
+
const result = await client.callTool({
|
|
221
|
+
name: "execute_blender_code",
|
|
222
|
+
arguments: { code, user_prompt: prompt }
|
|
223
|
+
}, void 0, { signal, timeout: 18e4 });
|
|
224
|
+
const text = result.content.filter((item) => item.type === "text").map((item) => item.text).join("\n");
|
|
225
|
+
if (result.isError || /^(Error executing code:|Rejected by safe mode)/.test(text))
|
|
226
|
+
throw new Error(text);
|
|
227
|
+
return text;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
} catch (error) {
|
|
231
|
+
await close();
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// lib/types/index.js
|
|
237
|
+
var inject = [
|
|
238
|
+
"studioHost",
|
|
239
|
+
"studioFiles",
|
|
240
|
+
"studioArtifacts",
|
|
241
|
+
"studioWorkspaces"
|
|
242
|
+
];
|
|
243
|
+
var digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
244
|
+
var python = (value) => JSON.stringify(JSON.stringify(value));
|
|
245
|
+
async function apply(ctx) {
|
|
246
|
+
const root = resolve2(ctx.studioHost.profileDirectory, "data/blender");
|
|
247
|
+
await mkdir(root, { recursive: true });
|
|
248
|
+
const running = /* @__PURE__ */ new Set(), stops = /* @__PURE__ */ new Set();
|
|
249
|
+
ctx.effect(() => async () => {
|
|
250
|
+
await Promise.allSettled([...stops].map((stop) => stop()));
|
|
251
|
+
});
|
|
252
|
+
async function model(workspaceId, path, bundleId) {
|
|
253
|
+
safePath(path);
|
|
254
|
+
const bundle = bundleId ? await ctx.studioArtifacts.getBundle(bundleId) : await ctx.studioArtifacts.bundle(workspaceId, path);
|
|
255
|
+
if (bundle.workspaceId !== workspaceId || bundle.path !== path || !bundle.model || bundle.kind !== "model")
|
|
256
|
+
throw new StudioError("\u8BF7\u9009\u62E9\u5305\u542B\u6E90\u5DE5\u7A0B\u7684\u6A21\u578B\u7248\u672C");
|
|
257
|
+
return bundle;
|
|
258
|
+
}
|
|
259
|
+
async function materialize(bundle, directory) {
|
|
260
|
+
for (const file of bundle.files) {
|
|
261
|
+
const target = resolve2(directory, safePath(file.path));
|
|
262
|
+
await mkdir(dirname(target), { recursive: true });
|
|
263
|
+
await writeFile2(target, (await ctx.studioArtifacts.readBundle(bundle.id, file.path)).bytes);
|
|
264
|
+
}
|
|
265
|
+
return resolve2(directory, bundle.model.sourcePath);
|
|
266
|
+
}
|
|
267
|
+
const service = {
|
|
268
|
+
status: blenderStatus,
|
|
269
|
+
async edit(context, input) {
|
|
270
|
+
if (context.session.permission === "read-only")
|
|
271
|
+
throw new StudioError("\u53EA\u8BFB\u4F1A\u8BDD\u4E0D\u80FD\u4FEE\u6539\u6A21\u578B", 403);
|
|
272
|
+
const path = safePath(input.path);
|
|
273
|
+
if (!/\.glb$/i.test(path))
|
|
274
|
+
throw new StudioError("\u6A21\u578B\u4EA4\u4ED8\u8DEF\u5F84\u5FC5\u987B\u4EE5 .glb \u7ED3\u5C3E");
|
|
275
|
+
if (input.code !== void 0 && (typeof input.code !== "string" || input.code.length > 1e5))
|
|
276
|
+
throw new StudioError("\u5EFA\u6A21\u811A\u672C\u8FC7\u957F\u6216\u65E0\u6548");
|
|
277
|
+
if ([input.attachmentId, input.sourcePath, input.bundleId].filter(Boolean).length > 1)
|
|
278
|
+
throw new StudioError("\u6BCF\u6B21\u53EA\u80FD\u9009\u62E9\u4E00\u4E2A\u57FA\u6A21\u6765\u6E90");
|
|
279
|
+
const workspaceId = context.session.workspace.id, key = context.session.workspace.path;
|
|
280
|
+
if (running.has(key))
|
|
281
|
+
throw new StudioError("\u6B64\u9879\u76EE\u5DF2\u6709\u5EFA\u6A21\u64CD\u4F5C\u6B63\u5728\u6267\u884C", 409);
|
|
282
|
+
running.add(key);
|
|
283
|
+
const directory = await mkdtemp(resolve2(root, "edit-")), controller = new AbortController();
|
|
284
|
+
const signal = AbortSignal.any([
|
|
285
|
+
controller.signal,
|
|
286
|
+
context.signal,
|
|
287
|
+
AbortSignal.timeout(24e4)
|
|
288
|
+
]);
|
|
289
|
+
let worker;
|
|
290
|
+
let opening, rendering;
|
|
291
|
+
const close = async () => {
|
|
292
|
+
controller.abort();
|
|
293
|
+
const currentWorker = worker ?? await opening?.catch(() => void 0);
|
|
294
|
+
await currentWorker?.close();
|
|
295
|
+
await rendering?.stop();
|
|
296
|
+
};
|
|
297
|
+
stops.add(close);
|
|
298
|
+
context.onCleanup?.(close);
|
|
299
|
+
try {
|
|
300
|
+
const names = [
|
|
301
|
+
path.replace(/\.glb$/i, ".blend"),
|
|
302
|
+
path,
|
|
303
|
+
path.replace(/\.glb$/i, ".png")
|
|
304
|
+
];
|
|
305
|
+
const before = /* @__PURE__ */ new Map();
|
|
306
|
+
for (const name of names) {
|
|
307
|
+
try {
|
|
308
|
+
before.set(name, (await ctx.studioFiles.read(workspaceId, name)).bytes);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error.code !== "ENOENT")
|
|
311
|
+
throw error;
|
|
312
|
+
before.set(name, null);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const previous = await ctx.studioArtifacts.versions(workspaceId, path);
|
|
316
|
+
const fingerprints = new Map([...before].map(([name, bytes2]) => [
|
|
317
|
+
name,
|
|
318
|
+
bytes2 ? digest(bytes2) : ""
|
|
319
|
+
]));
|
|
320
|
+
const current = previous.find((version) => version.model && !version.model.unverified && version.files.every((file) => fingerprints.get(file.path) === file.digest));
|
|
321
|
+
const explicitSource = !!(input.attachmentId || input.sourcePath || input.bundleId);
|
|
322
|
+
if (!explicitSource && [...before.values()].some(Boolean) && !current)
|
|
323
|
+
throw new StudioError("\u73B0\u6709\u5DE5\u7A0B\u6216\u9884\u89C8\u5C1A\u672A\u5173\u8054\u5DF2\u68C0\u67E5\u7684\u6A21\u578B\u7248\u672C\uFF0C\u8BF7\u7528 sourcePath \u660E\u786E\u5BFC\u5165\u5DE5\u7A0B\u6216\u66F4\u6362\u4EA4\u4ED8\u8DEF\u5F84");
|
|
324
|
+
let source;
|
|
325
|
+
if (input.bundleId)
|
|
326
|
+
source = await materialize(await model(workspaceId, path, input.bundleId), resolve2(directory, "source"));
|
|
327
|
+
else if (input.sourcePath || input.attachmentId) {
|
|
328
|
+
const file = input.attachmentId ? await ctx.studioFiles.readAttachment(workspaceId, input.attachmentId).then((value) => ({
|
|
329
|
+
path: value.attachment.name,
|
|
330
|
+
bytes: value.bytes
|
|
331
|
+
})) : await ctx.studioFiles.read(workspaceId, safePath(input.sourcePath));
|
|
332
|
+
if (file.bytes.length > 128 * 1024 * 1024)
|
|
333
|
+
throw new StudioError("\u6A21\u578B\u6587\u4EF6\u4E0A\u9650\u4E3A 128 MiB");
|
|
334
|
+
if (/\.zip$/i.test(file.path)) {
|
|
335
|
+
let total = 0, count = 0;
|
|
336
|
+
const entries = unzipSync(file.bytes, {
|
|
337
|
+
filter(entry) {
|
|
338
|
+
total += entry.originalSize;
|
|
339
|
+
count++;
|
|
340
|
+
if (total > 128 * 1024 * 1024 || count > 256)
|
|
341
|
+
throw new StudioError("\u57FA\u6A21\u8D44\u6E90\u5305\u8FC7\u5927");
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
const candidates = Object.keys(entries).filter((name) => /\.(fbx|glb|obj)$/i.test(name));
|
|
346
|
+
if (candidates.length !== 1)
|
|
347
|
+
throw new StudioError("\u57FA\u6A21 ZIP \u987B\u5305\u542B\u4E00\u4E2A FBX\u3001GLB \u6216 OBJ\uFF0C\u5E76\u53EF\u5305\u542B\u5BF9\u5E94\u8D34\u56FE");
|
|
348
|
+
for (const [name, bytes2] of Object.entries(entries)) {
|
|
349
|
+
if (name.endsWith("/"))
|
|
350
|
+
continue;
|
|
351
|
+
safePath(name);
|
|
352
|
+
if (!/\.(fbx|glb|obj|mtl|png|jpe?g|webp|tga|bmp)$/i.test(name))
|
|
353
|
+
throw new StudioError("\u57FA\u6A21\u8D44\u6E90\u5305\u542B\u4E0D\u652F\u6301\u7684\u6587\u4EF6\uFF1A" + name);
|
|
354
|
+
const target = resolve2(directory, "input", name);
|
|
355
|
+
await mkdir(dirname(target), { recursive: true });
|
|
356
|
+
await writeFile2(target, bytes2);
|
|
357
|
+
}
|
|
358
|
+
source = resolve2(directory, "input", candidates[0]);
|
|
359
|
+
} else {
|
|
360
|
+
if (!/\.(glb|fbx|blend|obj)$/i.test(file.path))
|
|
361
|
+
throw new StudioError("\u8BF7\u9009\u62E9 GLB\u3001FBX\u3001BLEND\u3001OBJ \u6216\u8D34\u56FE ZIP");
|
|
362
|
+
source = resolve2(directory, "input" + extname(file.path).toLowerCase());
|
|
363
|
+
await writeFile2(source, file.bytes);
|
|
364
|
+
}
|
|
365
|
+
} else if (current?.model) {
|
|
366
|
+
source = resolve2(directory, "input.blend");
|
|
367
|
+
await writeFile2(source, before.get(current.model.sourcePath));
|
|
368
|
+
}
|
|
369
|
+
const task = context.session.turns?.find((turn) => turn.id === context.turnId)?.applicationOptions;
|
|
370
|
+
if (!source && task?.type === "model" && task.model?.start === "import")
|
|
371
|
+
throw new StudioError("\u5BFC\u5165\u7CBE\u4FEE\u4EFB\u52A1\u9700\u8981\u5148\u63D0\u4F9B\u57FA\u6A21\u9644\u4EF6\u6216\u9879\u76EE\u6A21\u578B\u8DEF\u5F84");
|
|
372
|
+
const originals = names.filter((name) => before.get(name));
|
|
373
|
+
const original = originals.length ? await ctx.studioArtifacts.capture(workspaceId, before.get(path) ? path : originals[0], "\u4FEE\u6539\u524D\u539F\u4EF6", { files: originals }) : void 0;
|
|
374
|
+
const assets = {};
|
|
375
|
+
if (input.assets && (!Array.isArray(input.assets) || input.assets.length > 32))
|
|
376
|
+
throw new StudioError("\u6BCF\u6B21\u6700\u591A\u4F7F\u7528 32 \u4E2A\u7D20\u6750");
|
|
377
|
+
let assetBytes = 0;
|
|
378
|
+
for (const name of input.assets ?? []) {
|
|
379
|
+
safePath(name);
|
|
380
|
+
const file = await ctx.studioFiles.read(workspaceId, name);
|
|
381
|
+
assetBytes += file.bytes.length;
|
|
382
|
+
if (assetBytes > 128 * 1024 * 1024)
|
|
383
|
+
throw new StudioError("\u5EFA\u6A21\u7D20\u6750\u8D85\u8FC7 128 MiB");
|
|
384
|
+
const target = resolve2(directory, "assets", name);
|
|
385
|
+
await mkdir(dirname(target), { recursive: true });
|
|
386
|
+
await writeFile2(target, file.bytes);
|
|
387
|
+
assets[name] = target;
|
|
388
|
+
}
|
|
389
|
+
opening = startWorker(directory, source, signal);
|
|
390
|
+
worker = await opening;
|
|
391
|
+
let executionOutput = "";
|
|
392
|
+
if (input.code?.trim())
|
|
393
|
+
executionOutput = await worker.execute(`import json
|
|
394
|
+
ASSETS=json.loads(${python(assets)})
|
|
395
|
+
` + input.code, context.text);
|
|
396
|
+
const output = resolve2(directory, "output");
|
|
397
|
+
await mkdir(output);
|
|
398
|
+
const publish = `import json
|
|
399
|
+
OUTPUT_DIRECTORY=json.loads(${python(output)})
|
|
400
|
+
` + await readFile2(asset("publish.py"), "utf8");
|
|
401
|
+
await worker.execute(publish, context.text);
|
|
402
|
+
await worker.close();
|
|
403
|
+
worker = void 0;
|
|
404
|
+
for (const name of ["model.blend", "model.glb"])
|
|
405
|
+
if ((await stat(resolve2(output, name))).size > 128 * 1024 * 1024)
|
|
406
|
+
throw new StudioError("\u6A21\u578B\u6587\u4EF6\u8D85\u8FC7 128 MiB\uFF0C\u8BF7\u51CF\u5C11\u7F51\u683C\u6216\u8D34\u56FE\u5927\u5C0F");
|
|
407
|
+
const inspected = JSON.parse(await readFile2(resolve2(output, "model.json"), "utf8"));
|
|
408
|
+
const bytes = previewGlb(await readFile2(resolve2(output, "model.glb")), new Set(inspected.objects.map((object) => object.id)));
|
|
409
|
+
const environment = blenderEnvironment2(), config = resolve2(directory, "render.json");
|
|
410
|
+
await writeFile2(config, JSON.stringify({
|
|
411
|
+
source: resolve2(output, "model.blend"),
|
|
412
|
+
directory: output,
|
|
413
|
+
width: 320,
|
|
414
|
+
height: 320,
|
|
415
|
+
samples: 8
|
|
416
|
+
}));
|
|
417
|
+
rendering = processRun(environment.binary, [
|
|
418
|
+
"--background",
|
|
419
|
+
"--factory-startup",
|
|
420
|
+
"--disable-autoexec",
|
|
421
|
+
"--python",
|
|
422
|
+
asset("render.py"),
|
|
423
|
+
"--",
|
|
424
|
+
config
|
|
425
|
+
], environment.env, signal);
|
|
426
|
+
await rendering.done;
|
|
427
|
+
rendering = void 0;
|
|
428
|
+
const info = {
|
|
429
|
+
...inspected,
|
|
430
|
+
sourcePath: names[0],
|
|
431
|
+
thumbnailPath: names[2]
|
|
432
|
+
};
|
|
433
|
+
const contents = [
|
|
434
|
+
await readFile2(resolve2(output, "model.blend")),
|
|
435
|
+
bytes,
|
|
436
|
+
await readFile2(resolve2(output, "image.png"))
|
|
437
|
+
];
|
|
438
|
+
if (contents.reduce((size, value) => size + value.length, 0) > 128 * 1024 * 1024)
|
|
439
|
+
throw new StudioError("\u6A21\u578B\u7248\u672C\u8D85\u8FC7 128 MiB\uFF0C\u8BF7\u51CF\u5C11\u7F51\u683C\u6216\u8D34\u56FE\u5927\u5C0F");
|
|
440
|
+
const written = [];
|
|
441
|
+
try {
|
|
442
|
+
for (let i = 0; i < names.length; i++) {
|
|
443
|
+
signal.throwIfAborted();
|
|
444
|
+
const previous2 = before.get(names[i]);
|
|
445
|
+
await ctx.studioFiles.write(workspaceId, names[i], contents[i], previous2 ? { expectedDigest: digest(previous2) } : { createOnly: true });
|
|
446
|
+
written.push(i);
|
|
447
|
+
}
|
|
448
|
+
signal.throwIfAborted();
|
|
449
|
+
const artifact = await ctx.studioArtifacts.capture(workspaceId, path, posix.basename(path, ".glb"), { files: names, model: info });
|
|
450
|
+
await ctx.studioArtifacts.recordHistory?.(workspaceId, path, artifact.bundleId, "ai", context.text.slice(0, 120), context.turnId);
|
|
451
|
+
return {
|
|
452
|
+
text: JSON.stringify({
|
|
453
|
+
path,
|
|
454
|
+
model: info,
|
|
455
|
+
output: executionOutput.slice(0, 16e3),
|
|
456
|
+
originalVersion: original?.bundleId,
|
|
457
|
+
message: "\u6A21\u578B\u3001\u6E90\u5DE5\u7A0B\u548C\u9884\u89C8\u5DF2\u4FDD\u5B58\u4E3A\u540C\u4E00\u7248\u672C\u3002"
|
|
458
|
+
}),
|
|
459
|
+
image: {
|
|
460
|
+
mime: "image/png",
|
|
461
|
+
base64: Buffer.from(contents[2]).toString("base64")
|
|
462
|
+
},
|
|
463
|
+
artifacts: [artifact]
|
|
464
|
+
};
|
|
465
|
+
} catch (error) {
|
|
466
|
+
for (const i of written.reverse()) {
|
|
467
|
+
const previous2 = before.get(names[i]);
|
|
468
|
+
if (previous2)
|
|
469
|
+
await ctx.studioFiles.write(workspaceId, names[i], previous2, {
|
|
470
|
+
expectedDigest: digest(contents[i])
|
|
471
|
+
});
|
|
472
|
+
else
|
|
473
|
+
await ctx.studioFiles.remove(workspaceId, names[i], digest(contents[i]));
|
|
474
|
+
}
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
} finally {
|
|
478
|
+
await close();
|
|
479
|
+
stops.delete(close);
|
|
480
|
+
running.delete(key);
|
|
481
|
+
await rm(directory, { recursive: true, force: true });
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
async render(input, signal, report) {
|
|
485
|
+
const bundle = await model(input.workspaceId, input.path, input.bundleId);
|
|
486
|
+
if (bundle.model?.unverified)
|
|
487
|
+
throw new StudioError("\u5DE5\u7A0B\u6216\u9884\u89C8\u6709\u5916\u90E8\u4FEE\u6539\uFF0C\u8BF7\u5148\u901A\u8FC7\u5BFC\u5165\u6A21\u578B\u7CBE\u4FEE\u91CD\u65B0\u4FDD\u5B58");
|
|
488
|
+
const directory = input.directory;
|
|
489
|
+
await mkdir(directory, { recursive: true });
|
|
490
|
+
const source = await materialize(bundle, resolve2(directory, "source"));
|
|
491
|
+
const config = resolve2(directory, "render.json"), fps = input.fps ?? 24;
|
|
492
|
+
const frames = input.format === "png" ? 1 : Math.ceil((input.duration ?? 5) * fps);
|
|
493
|
+
await writeFile2(config, JSON.stringify({
|
|
494
|
+
source,
|
|
495
|
+
directory,
|
|
496
|
+
width: input.width,
|
|
497
|
+
height: input.height,
|
|
498
|
+
engine: input.engine ?? "cycles",
|
|
499
|
+
device: input.device ?? "auto",
|
|
500
|
+
transparent: input.transparent ?? false,
|
|
501
|
+
view: input.view,
|
|
502
|
+
frames,
|
|
503
|
+
animation: input.format === "mp4",
|
|
504
|
+
fps,
|
|
505
|
+
preset: input.preset ?? "turntable"
|
|
506
|
+
}));
|
|
507
|
+
const environment = blenderEnvironment2();
|
|
508
|
+
let progress = Promise.resolve(), markerBuffer = "";
|
|
509
|
+
const worker = processRun(environment.binary, [
|
|
510
|
+
"--background",
|
|
511
|
+
"--factory-startup",
|
|
512
|
+
"--disable-autoexec",
|
|
513
|
+
"--python",
|
|
514
|
+
asset("render.py"),
|
|
515
|
+
"--",
|
|
516
|
+
config
|
|
517
|
+
], environment.env, signal, (text) => {
|
|
518
|
+
markerBuffer += text;
|
|
519
|
+
const complete = markerBuffer.slice(0, markerBuffer.lastIndexOf("\n") + 1);
|
|
520
|
+
markerBuffer = markerBuffer.slice(complete.length);
|
|
521
|
+
for (const match of complete.matchAll(/AISTUDIO_FRAME (\d+) (\d+)/g))
|
|
522
|
+
progress = progress.then(async () => {
|
|
523
|
+
await report?.({
|
|
524
|
+
stage: "\u6E32\u67D3\u4E09\u7EF4\u955C\u5934",
|
|
525
|
+
completed: Number(match[1]),
|
|
526
|
+
total: Number(match[2])
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
void progress.catch(() => {
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
const stop = () => worker.stop();
|
|
533
|
+
stops.add(stop);
|
|
534
|
+
try {
|
|
535
|
+
await worker.done;
|
|
536
|
+
await progress;
|
|
537
|
+
return {
|
|
538
|
+
path: resolve2(directory, input.format === "png" ? "image.png" : "frame-%06d.png")
|
|
539
|
+
};
|
|
540
|
+
} finally {
|
|
541
|
+
await stop();
|
|
542
|
+
stops.delete(stop);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
ctx.reflect.provide("studioBlender", service);
|
|
547
|
+
ctx.effect(() => ctx.studioHost.registerRoute("/api/blender", api(async (request, url) => {
|
|
548
|
+
if (request.method !== "GET")
|
|
549
|
+
throw new StudioError("\u5EFA\u6A21\u64CD\u4F5C\u8BF7\u901A\u8FC7\u5F53\u524D\u8BBE\u8BA1\u4F1A\u8BDD\u6267\u884C", 405);
|
|
550
|
+
if (url.pathname === "/api/blender/status")
|
|
551
|
+
return Response.json(await service.status());
|
|
552
|
+
if (url.pathname === "/api/blender/model")
|
|
553
|
+
return Response.json(await model(url.searchParams.get("workspaceId") ?? "", url.searchParams.get("path") ?? "", url.searchParams.get("bundleId") || void 0));
|
|
554
|
+
throw new StudioError("\u63A5\u53E3\u4E0D\u5B58\u5728", 404);
|
|
555
|
+
})));
|
|
556
|
+
ctx.effect(() => ctx.studioHost.registerTool({
|
|
557
|
+
name: "aistudio_blender",
|
|
558
|
+
description: '\u5728\u72EC\u7ACB Blender \u4E2D\u53C2\u8003\u7167\u7247\u65B0\u5EFA\u6216\u5BFC\u5165\u57FA\u6A21\u7CBE\u4FEE\u3002code \u662F\u4F7F\u7528 bpy \u7684 Python\uFF1B\u5C3A\u5BF8\u4EE5\u7C73\u4E3A\u5355\u4F4D\u3002\u6267\u884C\u540E\u81EA\u52A8\u4FDD\u5B58 BLEND\u3001GLB \u548C\u7F29\u7565\u56FE\u5E76\u4EA4\u4ED8\u7248\u672C\u3002\u8FDE\u7EED\u4FEE\u6539\u9ED8\u8BA4\u8F7D\u5165\u5F53\u524D\u5DE5\u7A0B\uFF0CattachmentId \u5BFC\u5165\u5F53\u524D\u9879\u76EE\u9644\u4EF6\uFF1BbundleId \u4ECE\u6307\u5B9A\u5386\u53F2\u7248\u672C\u7EE7\u7EED\u3002\u9879\u76EE\u7D20\u6750\u901A\u8FC7 assets \u8DEF\u5F84\u6570\u7EC4\u4F20\u5165\uFF0C\u811A\u672C\u4F7F\u7528 ASSETS["assets/logo.png"] \u53D6\u5F97\u4E34\u65F6\u7D20\u6750\u8DEF\u5F84\u3002\u811A\u672C\u8FD0\u884C\u4E8E\u5DE5\u4F5C\u53F0\u53D7\u4FE1\u4EFB\u7684 Blender \u8FDB\u7A0B\u3002',
|
|
559
|
+
applications: ["opendesign"],
|
|
560
|
+
readOnly: false,
|
|
561
|
+
inputSchema: {
|
|
562
|
+
type: "object",
|
|
563
|
+
properties: {
|
|
564
|
+
path: { type: "string", description: "\u9879\u76EE\u76F8\u5BF9 GLB \u8DEF\u5F84" },
|
|
565
|
+
code: { type: "string" },
|
|
566
|
+
attachmentId: { type: "string" },
|
|
567
|
+
sourcePath: { type: "string" },
|
|
568
|
+
bundleId: { type: "string" },
|
|
569
|
+
assets: { type: "array", items: { type: "string" }, maxItems: 32 }
|
|
570
|
+
},
|
|
571
|
+
required: ["path"],
|
|
572
|
+
additionalProperties: false
|
|
573
|
+
},
|
|
574
|
+
execute: (context, input) => service.edit(context, input)
|
|
575
|
+
}));
|
|
576
|
+
ctx.effect(() => ctx.studioHost.registerTool({
|
|
577
|
+
name: "aistudio_model_info",
|
|
578
|
+
description: "\u8BFB\u53D6\u6A21\u578B\u7248\u672C\u4E2D\u7684\u96F6\u4EF6\u6807\u8BC6\u3001\u540D\u79F0\u3001\u5C3A\u5BF8\u4E0E\u6E90\u5DE5\u7A0B\u8DEF\u5F84\u3002\u4FEE\u6539\u524D\u5148\u68C0\u67E5\u3002",
|
|
579
|
+
applications: ["opendesign"],
|
|
580
|
+
readOnly: true,
|
|
581
|
+
inputSchema: {
|
|
582
|
+
type: "object",
|
|
583
|
+
properties: { path: { type: "string" }, bundleId: { type: "string" } },
|
|
584
|
+
required: ["path"],
|
|
585
|
+
additionalProperties: false
|
|
586
|
+
},
|
|
587
|
+
async execute(context, value) {
|
|
588
|
+
const input = value;
|
|
589
|
+
const bundle = await model(context.session.workspace.id, input.path, input.bundleId);
|
|
590
|
+
return {
|
|
591
|
+
text: JSON.stringify({
|
|
592
|
+
id: bundle.id,
|
|
593
|
+
path: bundle.path,
|
|
594
|
+
kind: bundle.kind,
|
|
595
|
+
createdAt: bundle.createdAt,
|
|
596
|
+
model: bundle.model
|
|
597
|
+
})
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
}));
|
|
601
|
+
}
|
|
602
|
+
export {
|
|
603
|
+
apply,
|
|
604
|
+
inject
|
|
605
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const asset: (name: string) => string;
|
|
2
|
+
export declare function processRun(command: string, args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, onLine?: (text: string) => void): {
|
|
3
|
+
done: Promise<void>;
|
|
4
|
+
readonly output: string;
|
|
5
|
+
stop(): Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
export declare function startWorker(directory: string, source: string | undefined, signal: AbortSignal): Promise<{
|
|
8
|
+
close: () => Promise<void>;
|
|
9
|
+
execute(code: string, prompt: string): Promise<string>;
|
|
10
|
+
}>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
|
+
"version": "0.2.1",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"license": "UNLICENSED",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public",
|
|
7
|
+
"registry": "https://registry.npmjs.org/"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=24.9.0"
|
|
11
|
+
},
|
|
2
12
|
"name": "@next2012/aistudio-blender",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
13
|
+
"description": "Blender 建模、导入和三维渲染",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./lib/types/index.d.ts",
|
|
18
|
+
"default": "./lib/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json",
|
|
21
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib/*.js",
|
|
25
|
+
"lib/types/**/*.d.ts",
|
|
26
|
+
"cordis.patch.yml",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"README.md",
|
|
29
|
+
"lib/assets/*"
|
|
30
|
+
],
|
|
31
|
+
"dsh": {
|
|
32
|
+
"bundle": {
|
|
33
|
+
"patch": "./cordis.patch.yml"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@next2012/aistudio-sdk": "^0.6.0",
|
|
38
|
+
"@deepseek-ai/cordis": "~4.0.5",
|
|
39
|
+
"react": "^18.3.1",
|
|
40
|
+
"react-dom": "^18.3.1"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@next2012/aistudio-sdk": "^0.6.0",
|
|
44
|
+
"@deepseek-ai/cordis": "4.0.5",
|
|
45
|
+
"react": "18.3.1",
|
|
46
|
+
"react-dom": "18.3.1"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
50
|
+
"@next2012/aistudio-blender-runtime": "^0.2.1",
|
|
51
|
+
"fflate": "0.8.2"
|
|
52
|
+
},
|
|
53
|
+
"aistudio": {
|
|
54
|
+
"runtime": {
|
|
55
|
+
"package": "@next2012/aistudio-blender-runtime",
|
|
56
|
+
"python": true,
|
|
57
|
+
"browser": false
|
|
58
|
+
},
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
6
61
|
}
|