streamby-core 0.37.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +62 -1
- package/dist/adapters/storage/s3.d.ts +3 -2
- package/dist/adapters/storage/s3.js +18 -5
- package/dist/mcp/server.js +12 -0
- package/dist/mcp/tools.d.ts +2 -0
- package/dist/mcp/tools.js +107 -14
- package/dist/mcp/workflowNodes.d.ts +24 -0
- package/dist/mcp/workflowNodes.js +48 -0
- package/dist/middleware/createRouter.js +2 -0
- package/dist/middleware/routes/deliverable.d.ts +3 -0
- package/dist/middleware/routes/deliverable.js +131 -0
- package/dist/middleware/routes/export.js +2 -0
- package/dist/middleware/routes/production.js +60 -67
- package/dist/middleware/routes/project.js +8 -2
- package/dist/middleware/routes/storage.js +115 -24
- package/dist/services/apiTokenPermissions.js +5 -1
- package/dist/services/assetVersions.d.ts +48 -0
- package/dist/services/assetVersions.js +84 -0
- package/dist/services/deliverables.d.ts +26 -0
- package/dist/services/deliverables.js +55 -0
- package/dist/services/mediaProcessor.js +3 -1
- package/dist/services/production.d.ts +2 -0
- package/dist/services/production.js +13 -0
- package/dist/services/productionFields.d.ts +16 -0
- package/dist/services/productionFields.js +80 -0
- package/dist/services/signedUrls.d.ts +6 -0
- package/dist/services/signedUrls.js +9 -0
- package/dist/types/index.d.ts +7 -2
- package/dist/utils/nosqlDb.d.ts +2 -0
- package/dist/utils/nosqlDb.js +11 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.38.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Uploading and downloading files from the MCP server**, so an agent can bring files from and
|
|
8
|
+
to the tools it works with (Blender's or Godot's own MCP, a script). The file never goes
|
|
9
|
+
through the conversation: the tools give signed URLs, and the tool that has the file uploads
|
|
10
|
+
or downloads it directly. See the README for an example with Blender.
|
|
11
|
+
- `request_asset_upload` (`assets:write`): a new asset, or a new version of one, and the URL
|
|
12
|
+
to upload its file to.
|
|
13
|
+
- `get_asset_download_url` (`assets:read`): a URL to download an asset's file, its latest
|
|
14
|
+
version or an earlier one. `list_asset_versions` lists them.
|
|
15
|
+
- **Asset versions are kept, and an asset's URL doesn't change when it's replaced.** A new
|
|
16
|
+
version (`replace-url`, from the UI too) is still uploaded to the asset's own key, so a site
|
|
17
|
+
that links to the asset shows the new one. Before that, the current version is copied to a
|
|
18
|
+
key of its own, `<key>.v<n>.<ext>`, and recorded in `asset_versions` (an asset's first
|
|
19
|
+
version is recorded on upload; an older asset gets it when it's first replaced). If it can't
|
|
20
|
+
be copied, nothing is replaced and no upload URL is given. The `replace-url` routes answer
|
|
21
|
+
the new version's `versionId` and `version`. Deleting an asset deletes every key of it.
|
|
22
|
+
- New `StorageAdapter.copyFile`, implemented by the S3 adapter. With an adapter without it,
|
|
23
|
+
asking for a new version answers 501.
|
|
24
|
+
- StreamBy doesn't set `Cache-Control` on uploads, so a browser or a CDN may serve a replaced
|
|
25
|
+
asset's earlier version for a while: set it where the files are served (see the README).
|
|
26
|
+
- `GET /projects/:projectId/assets/:fileId/download-url` (`?versionId=` for an earlier version):
|
|
27
|
+
a signed URL that downloads the file with its name, wherever it's stored.
|
|
28
|
+
- **Deliverables**: an asset, or one of its versions, delivered for a production shot or for an
|
|
29
|
+
export, kept in the NoSQL database's `deliverables` collection. `GET`, `POST` and
|
|
30
|
+
`DELETE /projects/:projectId/deliverables` list (by `shotId` or `exportId`), mark and remove
|
|
31
|
+
them; `list_deliverables`, `mark_deliverable` and `remove_deliverable` in the MCP server.
|
|
32
|
+
Deleting a shot, a sequence, an export or an asset removes its deliverables.
|
|
33
|
+
- **`mcp.signedUrlTtl`**: how long the signed URLs given to an API token work, also when it
|
|
34
|
+
lists files. Default: 15 minutes to upload, 5 to download. A session's are as before.
|
|
35
|
+
- `StorageAdapter.getPresignedUploadUrl` and `getPresignedGetUrl` take how long the URL works
|
|
36
|
+
(and `getPresignedGetUrl` the name to download it with).
|
|
37
|
+
|
|
38
|
+
- `GET /projects/:id` answers `hasProductionData`: whether the project has production
|
|
39
|
+
sequences, so a client can show them even without a production node in its workflow.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **Production data is checked before it's saved.** Sequences, shots and tasks need a name
|
|
44
|
+
that isn't blank; a status must be `todo`, `in_progress`, `review` or `done`, and a task's
|
|
45
|
+
priority `low`, `medium` or `high`; a shot's `assignedTo` must list active members of the
|
|
46
|
+
project, and a task's be one of them (or null); a due date must be a date. Otherwise they
|
|
47
|
+
answer 400. A shot is made only in a sequence of the project, and a task only in a shot of
|
|
48
|
+
it (404 otherwise).
|
|
49
|
+
- The MCP tools for shots take `assignedTo` as a list of user ids, as the API does, and their
|
|
50
|
+
statuses and priorities are listed in their schema.
|
|
51
|
+
- **The MCP tools take the path a user does.** `create_sequence`, `create_shot` and
|
|
52
|
+
`create_task` first add a production node (a Shot node) to the project's workflow when it
|
|
53
|
+
has none, as dropping one on the canvas does, and make nothing if it can't be added. So they
|
|
54
|
+
need `workflow:write` as well as `production:write`. Each tool's description says where its
|
|
55
|
+
result shows in the UI.
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- **Deleting production data leaves nothing behind.** Deleting a sequence deletes its shots'
|
|
60
|
+
tasks too (they were left), and deleting a project deletes its sequences, shots, tasks and
|
|
61
|
+
deliverables. Deleting a sequence that doesn't exist answers 404.
|
|
62
|
+
- **Only editors and admins upload a new version of an asset.** The `replace-url` routes let
|
|
63
|
+
any member of the project, a viewer included, replace an asset's file.
|
|
64
|
+
- The ingest job no longer records a second version of the file an upload already recorded.
|
|
65
|
+
|
|
3
66
|
## 0.37.0
|
|
4
67
|
|
|
5
68
|
### ⚠️ Breaking changes
|
package/README.md
CHANGED
|
@@ -203,12 +203,72 @@ A token sees only the tools whose permissions it has.
|
|
|
203
203
|
| `get_export` | `exports:read` |
|
|
204
204
|
| `get_job` | `jobs:read` |
|
|
205
205
|
| `list_sequences`, `list_shots`, `list_tasks` | `production:read` |
|
|
206
|
-
| `
|
|
206
|
+
| `update_sequence`, `update_shot`, `update_task` | `production:write` |
|
|
207
|
+
| `create_sequence`, `create_shot`, `create_task` | `production:write` and `workflow:write` |
|
|
207
208
|
| `delete_sequence`, `delete_shot`, `delete_task` | `production:delete` |
|
|
209
|
+
| `list_asset_versions`, `get_asset_download_url`, `list_deliverables` | `assets:read` |
|
|
210
|
+
| `request_asset_upload`, `mark_deliverable`, `remove_deliverable` | `assets:write` |
|
|
211
|
+
|
|
212
|
+
The tools take the path a user does in the UI, and each one's description says where its
|
|
213
|
+
result shows there. Production data lives under a production node of the project's workflow:
|
|
214
|
+
if the workflow has none yet, `create_sequence`, `create_shot` and `create_task` first add a
|
|
215
|
+
Shot node to it, as dropping one on the canvas does (so they need `workflow:write`), and make
|
|
216
|
+
nothing if it can't be added.
|
|
208
217
|
|
|
209
218
|
The tools that delete are marked as destructive (`destructiveHint`), so a client can ask before
|
|
210
219
|
running them.
|
|
211
220
|
|
|
221
|
+
**Files**
|
|
222
|
+
|
|
223
|
+
A file never goes through the conversation. `request_asset_upload` makes a new asset (or a new
|
|
224
|
+
version of one) and gives a signed URL to `PUT` its bytes to; `get_asset_download_url` gives a
|
|
225
|
+
signed URL to `GET` an asset's file, its latest version or any earlier one. The tool that has
|
|
226
|
+
the file uploads or downloads it directly, for example Blender's or Godot's own MCP server,
|
|
227
|
+
which can run code. Each URL is for that one file, and given to an API token it works only
|
|
228
|
+
briefly: 15 minutes to upload, 5 to download (`mcp.signedUrlTtl`).
|
|
229
|
+
|
|
230
|
+
**Versions.** Replacing an asset doesn't change its URL: the new version is uploaded to the
|
|
231
|
+
asset's own key, so a site that links to the asset shows the new one with no deploy. Before
|
|
232
|
+
that, the current version is copied to a key of its own (`<key>.v<n>.<ext>`) and recorded, so
|
|
233
|
+
every earlier version can still be downloaded by its id; if it can't be copied, nothing is
|
|
234
|
+
replaced. A storage adapter keeps versions only if it can copy (`copyFile`; the S3 one does);
|
|
235
|
+
otherwise asking for a new version answers 501. Deleting the asset deletes every key of it.
|
|
236
|
+
|
|
237
|
+
Since the URL stays the same, a browser or a CDN may keep serving the earlier version for a
|
|
238
|
+
while. StreamBy doesn't set `Cache-Control` on uploads (a signed URL that did would require
|
|
239
|
+
every client to send the same header): set it where the files are served, for example a short
|
|
240
|
+
`max-age` or `no-cache` on the CDN in front of the bucket, or invalidate the path when an asset
|
|
241
|
+
is replaced.
|
|
242
|
+
|
|
243
|
+
A **deliverable** is an asset, or one of its versions, delivered for a
|
|
244
|
+
production shot or for an export; a shot or an export can have several, and the shot's panel
|
|
245
|
+
in the UI shows them.
|
|
246
|
+
|
|
247
|
+
For example, with Claude connected to StreamBy and to Blender's MCP server, asking *"export the
|
|
248
|
+
scene as glTF and deliver it for shot SH010 of Portfolio"* goes:
|
|
249
|
+
|
|
250
|
+
1. `list_projects`, `list_sequences`, `list_shots`: the project and the shot's id.
|
|
251
|
+
2. `request_asset_upload` with `category: '3d-models'`, `fileName: 'SH010_layout.glb'` and
|
|
252
|
+
`contentType: 'model/gltf-binary'`: the new asset's id, and where to upload it.
|
|
253
|
+
3. In Blender, code that exports the scene and uploads it:
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
import bpy, urllib.request
|
|
257
|
+
path = bpy.app.tempdir + "SH010_layout.glb"
|
|
258
|
+
bpy.ops.export_scene.gltf(filepath=path, export_format="GLB")
|
|
259
|
+
with open(path, "rb") as f:
|
|
260
|
+
req = urllib.request.Request(UPLOAD_URL, data=f.read(), method="PUT",
|
|
261
|
+
headers={"Content-Type": "model/gltf-binary"})
|
|
262
|
+
urllib.request.urlopen(req)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
4. `mark_deliverable` with the asset's id and `shotId`: it shows in the shot's panel.
|
|
266
|
+
|
|
267
|
+
Bringing an asset into Blender or Godot is the other way around: `get_asset_download_url`, and
|
|
268
|
+
code there that downloads the URL and imports the file (in Blender,
|
|
269
|
+
`bpy.ops.import_scene.gltf(filepath=...)`). A new version of the same asset is
|
|
270
|
+
`request_asset_upload` with its `assetId`.
|
|
271
|
+
|
|
212
272
|
**Limits and audit**
|
|
213
273
|
|
|
214
274
|
- Each token can make at most 120 requests a minute (a sliding window). Past that, StreamBy
|
|
@@ -224,6 +284,7 @@ createStreamByRouter({
|
|
|
224
284
|
// ...
|
|
225
285
|
mcp: {
|
|
226
286
|
rateLimit: { requests: 120, windowMs: 60_000 }, // the default
|
|
287
|
+
signedUrlTtl: { upload: 900, download: 300 }, // the default, in seconds
|
|
227
288
|
// apiBaseUrl: 'http://127.0.0.1:3000/streamby', // where tools reach the REST API;
|
|
228
289
|
// by default this same process, at the request's local port and mount path
|
|
229
290
|
},
|
|
@@ -8,8 +8,9 @@ export declare class S3Adapter implements StorageAdapter {
|
|
|
8
8
|
deleteProjectDirectory(projectId: string): Promise<any>;
|
|
9
9
|
getPresignedUrl(contentType: string, projectId: string): Promise<any>;
|
|
10
10
|
getPresignedProjectImageUrl(projectId: string): Promise<any>;
|
|
11
|
-
getPresignedGetUrl(key: string): Promise<string>;
|
|
12
|
-
getPresignedUploadUrl(key: string, contentType: string): Promise<string>;
|
|
11
|
+
getPresignedGetUrl(key: string, expiresIn?: number, downloadName?: string): Promise<string>;
|
|
12
|
+
getPresignedUploadUrl(key: string, contentType: string, expiresIn?: number): Promise<string>;
|
|
13
|
+
copyFile(from: string, to: string): Promise<void>;
|
|
13
14
|
deleteFile(key: string): Promise<void>;
|
|
14
15
|
listFilesByCategory(projectId: string, category: string): Promise<StorageFileInfo[]>;
|
|
15
16
|
private inferMimeType;
|
|
@@ -64,13 +64,21 @@ class S3Adapter {
|
|
|
64
64
|
const publicUrl = `https://${this.bucket}.s3.amazonaws.com/${key}`;
|
|
65
65
|
return { url, publicUrl };
|
|
66
66
|
}
|
|
67
|
-
async getPresignedGetUrl(key) {
|
|
68
|
-
const command = new client_s3_1.GetObjectCommand({
|
|
69
|
-
|
|
67
|
+
async getPresignedGetUrl(key, expiresIn = 86400, downloadName) {
|
|
68
|
+
const command = new client_s3_1.GetObjectCommand({
|
|
69
|
+
Bucket: this.bucket,
|
|
70
|
+
Key: key,
|
|
71
|
+
...(downloadName ? { ResponseContentDisposition: contentDisposition(downloadName) } : {}),
|
|
72
|
+
});
|
|
73
|
+
return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn });
|
|
70
74
|
}
|
|
71
|
-
async getPresignedUploadUrl(key, contentType) {
|
|
75
|
+
async getPresignedUploadUrl(key, contentType, expiresIn = 3600) {
|
|
72
76
|
const command = new client_s3_1.PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType });
|
|
73
|
-
return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn
|
|
77
|
+
return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn });
|
|
78
|
+
}
|
|
79
|
+
async copyFile(from, to) {
|
|
80
|
+
const source = `${this.bucket}/${from.split('/').map(encodeURIComponent).join('/')}`;
|
|
81
|
+
await this.s3.send(new client_s3_1.CopyObjectCommand({ Bucket: this.bucket, CopySource: source, Key: to }));
|
|
74
82
|
}
|
|
75
83
|
async deleteFile(key) {
|
|
76
84
|
const command = new client_s3_1.DeleteObjectCommand({ Bucket: this.bucket, Key: key });
|
|
@@ -114,3 +122,8 @@ class S3Adapter {
|
|
|
114
122
|
}
|
|
115
123
|
}
|
|
116
124
|
exports.S3Adapter = S3Adapter;
|
|
125
|
+
// An attachment with its name: ASCII in `filename`, the full name in `filename*` (RFC 6266).
|
|
126
|
+
const contentDisposition = (name) => {
|
|
127
|
+
const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, '_');
|
|
128
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
129
|
+
};
|
package/dist/mcp/server.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.createMcpServer = createMcpServer;
|
|
|
5
5
|
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
6
6
|
const tools_1 = require("./tools");
|
|
7
7
|
const audit_1 = require("./audit");
|
|
8
|
+
const workflowNodes_1 = require("./workflowNodes");
|
|
8
9
|
exports.MCP_SERVER_NAME = 'streamby';
|
|
9
10
|
const errorText = (body, status) => {
|
|
10
11
|
const message = body?.message ?? body?.error;
|
|
@@ -34,6 +35,17 @@ function createMcpServer(auth, api, config, version) {
|
|
|
34
35
|
const projectId = tool.projectOf?.(args) ?? tokenProject;
|
|
35
36
|
let status = null;
|
|
36
37
|
try {
|
|
38
|
+
// What the workflow needs first, as a user would add it.
|
|
39
|
+
if (tool.workflowNode) {
|
|
40
|
+
const refused = await (0, workflowNodes_1.ensureWorkflowNode)(tool.workflowNode, tool.projectOf?.(args), auth, api);
|
|
41
|
+
if (refused) {
|
|
42
|
+
await (0, audit_1.recordMcpCall)({
|
|
43
|
+
userId: auth.userId, tokenId: auth.apiToken.tokenId, projectId, tool: tool.name,
|
|
44
|
+
ok: false, status: null, error: refused, durationMs: Date.now() - started,
|
|
45
|
+
}, config);
|
|
46
|
+
return { isError: true, content: [{ type: 'text', text: refused }] };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
37
49
|
const answer = await api(tool.call(args));
|
|
38
50
|
status = answer.status;
|
|
39
51
|
const ok = status >= 200 && status < 300;
|
package/dist/mcp/tools.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ApiTokenPermission } from '../services/apiTokenPermissions';
|
|
3
|
+
import type { WorkflowNodeRequirement } from './workflowNodes';
|
|
3
4
|
export interface ToolCall {
|
|
4
5
|
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
|
5
6
|
path: string;
|
|
@@ -12,6 +13,7 @@ export interface ToolDefinition<S extends z.ZodRawShape = z.ZodRawShape> {
|
|
|
12
13
|
permissions: ApiTokenPermission[];
|
|
13
14
|
write?: boolean;
|
|
14
15
|
destructive?: boolean;
|
|
16
|
+
workflowNode?: WorkflowNodeRequirement;
|
|
15
17
|
input: S;
|
|
16
18
|
projectOf?: (args: z.infer<z.ZodObject<S>>) => string | undefined;
|
|
17
19
|
call: (args: z.infer<z.ZodObject<S>>) => ToolCall;
|
package/dist/mcp/tools.js
CHANGED
|
@@ -8,19 +8,20 @@ const projectId = id('project');
|
|
|
8
8
|
const enc = encodeURIComponent;
|
|
9
9
|
const byProject = ({ projectId }) => projectId;
|
|
10
10
|
const CATEGORIES = ['images', 'audios', 'videos', '3d-models'];
|
|
11
|
+
const STATUSES = ['todo', 'in_progress', 'review', 'done'];
|
|
11
12
|
const shotFields = {
|
|
12
13
|
description: zod_1.z.string().optional(),
|
|
13
|
-
status: zod_1.z.
|
|
14
|
-
assignedTo: zod_1.z.string().optional().describe("The
|
|
14
|
+
status: zod_1.z.enum(STATUSES).optional(),
|
|
15
|
+
assignedTo: zod_1.z.array(zod_1.z.string()).optional().describe("The assignees' user ids: members of the project"),
|
|
15
16
|
assetId: zod_1.z.string().optional(),
|
|
16
17
|
exportId: zod_1.z.string().optional(),
|
|
17
18
|
dueDate: zod_1.z.string().optional().describe('An ISO 8601 date'),
|
|
18
19
|
order: zod_1.z.number().optional(),
|
|
19
20
|
};
|
|
20
21
|
const taskFields = {
|
|
21
|
-
status: zod_1.z.
|
|
22
|
-
priority: zod_1.z.
|
|
23
|
-
assignedTo: zod_1.z.string().optional().describe("The assignee's user id"),
|
|
22
|
+
status: zod_1.z.enum(STATUSES).optional(),
|
|
23
|
+
priority: zod_1.z.enum(['low', 'medium', 'high']).optional(),
|
|
24
|
+
assignedTo: zod_1.z.string().optional().describe("The assignee's user id: a member of the project"),
|
|
24
25
|
dueDate: zod_1.z.string().optional().describe('An ISO 8601 date'),
|
|
25
26
|
notes: zod_1.z.string().optional(),
|
|
26
27
|
};
|
|
@@ -156,21 +157,21 @@ exports.TOOLS = [
|
|
|
156
157
|
// ── Writing ──
|
|
157
158
|
tool({
|
|
158
159
|
name: 'update_asset_metadata', title: "Update an asset's custom tags", permissions: ['assets:write'], write: true,
|
|
159
|
-
description: "Sets an asset's custom tags (key/value pairs), replacing the ones it had.",
|
|
160
|
+
description: "Sets an asset's custom tags (key/value pairs), replacing the ones it had. They show in the file's Metadata tab, in Storage.",
|
|
160
161
|
input: { projectId, fileId: id('asset'), customTags: zod_1.z.record(zod_1.z.string(), zod_1.z.string()) },
|
|
161
162
|
projectOf: byProject,
|
|
162
163
|
call: ({ projectId, fileId, customTags }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/assets/${enc(fileId)}/metadata`, body: { customTags } }),
|
|
163
164
|
}),
|
|
164
165
|
tool({
|
|
165
166
|
name: 'set_asset_rights', title: "Set an asset's rights", permissions: ['rights:write'], write: true,
|
|
166
|
-
description: "Sets an asset's rights: holder, license, territory, expiry, restrictions and notes.",
|
|
167
|
+
description: "Sets an asset's rights: holder, license, territory, expiry, restrictions and notes. They show in the file's Rights tab, in Storage.",
|
|
167
168
|
input: { projectId, assetId: id('asset'), ...rightsFields },
|
|
168
169
|
projectOf: byProject,
|
|
169
170
|
call: ({ projectId, assetId, ...rights }) => ({ method: 'PUT', path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/rights`, body: defined(rights) }),
|
|
170
171
|
}),
|
|
171
172
|
tool({
|
|
172
173
|
name: 'create_pipeline', title: 'Create a pipeline', permissions: ['workflow:write'], write: true,
|
|
173
|
-
description:
|
|
174
|
+
description: "Creates a pipeline in a project. It shows under Pipelines, and the workflow canvas draws its node.",
|
|
174
175
|
input: { projectId, name: zod_1.z.string().min(1), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
|
|
175
176
|
projectOf: byProject,
|
|
176
177
|
call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/pipelines`, body: defined(fields) }),
|
|
@@ -183,8 +184,8 @@ exports.TOOLS = [
|
|
|
183
184
|
call: ({ projectId, pipelineId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/pipelines/${enc(pipelineId)}`, body: defined(fields) }),
|
|
184
185
|
}),
|
|
185
186
|
tool({
|
|
186
|
-
name: 'create_sequence', title: 'Create a production sequence', permissions: ['production:write'], write: true,
|
|
187
|
-
description:
|
|
187
|
+
name: 'create_sequence', title: 'Create a production sequence', permissions: ['production:write', 'workflow:write'], write: true, workflowNode: 'production',
|
|
188
|
+
description: "Creates a production sequence in a project, shown in its Production board. If the project's workflow has no production node yet, it first adds one (a Shot node, as dropping it on the canvas does), so the project's Production section shows it: that's why it also needs workflow:write.",
|
|
188
189
|
input: { projectId, name: zod_1.z.string().min(1), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
|
|
189
190
|
projectOf: byProject,
|
|
190
191
|
call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/sequences`, body: defined(fields) }),
|
|
@@ -197,8 +198,8 @@ exports.TOOLS = [
|
|
|
197
198
|
call: ({ projectId, sequenceId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/sequences/${enc(sequenceId)}`, body: defined(fields) }),
|
|
198
199
|
}),
|
|
199
200
|
tool({
|
|
200
|
-
name: 'create_shot', title: 'Create a shot', permissions: ['production:write'], write: true,
|
|
201
|
-
description:
|
|
201
|
+
name: 'create_shot', title: 'Create a shot', permissions: ['production:write', 'workflow:write'], write: true, workflowNode: 'production',
|
|
202
|
+
description: "Creates a shot in a production sequence, shown in its Production board. If the project's workflow has no production node yet, it first adds one (a Shot node, as dropping it on the canvas does), so the project's Production section shows it: that's why it also needs workflow:write.",
|
|
202
203
|
input: { projectId, sequenceId: id('sequence'), name: zod_1.z.string().min(1), ...shotFields },
|
|
203
204
|
projectOf: byProject,
|
|
204
205
|
call: ({ projectId, sequenceId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/sequences/${enc(sequenceId)}/shots`, body: defined(fields) }),
|
|
@@ -211,8 +212,8 @@ exports.TOOLS = [
|
|
|
211
212
|
call: ({ projectId, shotId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}`, body: defined(fields) }),
|
|
212
213
|
}),
|
|
213
214
|
tool({
|
|
214
|
-
name: 'create_task', title: 'Create a shot task', permissions: ['production:write'], write: true,
|
|
215
|
-
description:
|
|
215
|
+
name: 'create_task', title: 'Create a shot task', permissions: ['production:write', 'workflow:write'], write: true, workflowNode: 'production',
|
|
216
|
+
description: "Creates a task in a shot, shown in the shot's panel in the Production board. If the project's workflow has no production node yet, it first adds one (a Shot node, as dropping it on the canvas does), so the project's Production section shows it: that's why it also needs workflow:write.",
|
|
216
217
|
input: { projectId, shotId: id('shot'), name: zod_1.z.string().min(1), ...taskFields },
|
|
217
218
|
projectOf: byProject,
|
|
218
219
|
call: ({ projectId, shotId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}/tasks`, body: defined(fields) }),
|
|
@@ -224,6 +225,98 @@ exports.TOOLS = [
|
|
|
224
225
|
projectOf: byProject,
|
|
225
226
|
call: ({ projectId, shotId, taskId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}/tasks/${enc(taskId)}`, body: defined(fields) }),
|
|
226
227
|
}),
|
|
228
|
+
// ── Files: uploading, downloading, deliverables ──
|
|
229
|
+
// The file itself never goes through the conversation: these tools give a signed URL, and
|
|
230
|
+
// the user's own tool (Blender's or Godot's MCP, a script) uploads or downloads it directly.
|
|
231
|
+
tool({
|
|
232
|
+
name: 'list_asset_versions', title: "List an asset's versions", permissions: ['assets:read'],
|
|
233
|
+
description: "An asset's versions, newest first: each one's id, number, file name and when it was made. Any of them can be downloaded (get_asset_download_url).",
|
|
234
|
+
input: { projectId, assetId: id('asset') },
|
|
235
|
+
projectOf: byProject,
|
|
236
|
+
call: ({ projectId, assetId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/versions` }),
|
|
237
|
+
pick: (data) => (data?.versions ?? []).map((v) => ({
|
|
238
|
+
versionId: v.versionId, version: v.version, fileName: v.fileName ?? null, contentType: v.contentType ?? null, createdAt: v.createdAt,
|
|
239
|
+
})),
|
|
240
|
+
}),
|
|
241
|
+
tool({
|
|
242
|
+
name: 'request_asset_upload', title: 'Request an upload URL', permissions: ['assets:write'], write: true,
|
|
243
|
+
description: [
|
|
244
|
+
"Makes a new asset (or, with assetId, a new version of one) and gives a signed URL to upload its file to, as the UI does.",
|
|
245
|
+
"It doesn't upload anything: the file is sent by the tool that has it (for example Blender's MCP, running Python), with an HTTP PUT of its bytes to `upload.url` and the `upload.headers` given, before `expiresAt`.",
|
|
246
|
+
"The URL works for that file only. A new version keeps the asset's URL (it replaces what it serves); the earlier ones are archived and can still be downloaded.",
|
|
247
|
+
"Categories: images, audios, videos, 3d-models (.glb, .gltf, .obj, .fbx, .stl, .ply); contentType must match it.",
|
|
248
|
+
].join(' '),
|
|
249
|
+
input: {
|
|
250
|
+
projectId,
|
|
251
|
+
category: zod_1.z.enum(CATEGORIES),
|
|
252
|
+
fileName: zod_1.z.string().min(1).describe('The file name, with its extension (e.g. shot010_layout.glb)'),
|
|
253
|
+
contentType: zod_1.z.string().min(1).describe('Its MIME type (e.g. model/gltf-binary, image/png, video/mp4)'),
|
|
254
|
+
assetId: zod_1.z.string().optional().describe('An existing asset to upload a new version of; a new asset if not given'),
|
|
255
|
+
connectionId: zod_1.z.string().optional().describe("A storage connection to store it in; the project's built-in storage if not given"),
|
|
256
|
+
},
|
|
257
|
+
projectOf: byProject,
|
|
258
|
+
call: ({ projectId, category, fileName, contentType, assetId, connectionId }) => {
|
|
259
|
+
const query = new URLSearchParams({ fileName, contentType, ...(connectionId && !assetId ? { category } : {}) });
|
|
260
|
+
const files = connectionId ? `/projects/${enc(projectId)}/connections/storage/${enc(connectionId)}` : `/projects/${enc(projectId)}/storage`;
|
|
261
|
+
const path = assetId
|
|
262
|
+
? `${files}/files/${enc(assetId)}/replace-url`
|
|
263
|
+
: connectionId ? `${files}/upload-url` : `${files}/${category}/upload-url`;
|
|
264
|
+
return { method: 'GET', path: `${path}?${query}` };
|
|
265
|
+
},
|
|
266
|
+
pick: (data, { contentType }) => ({
|
|
267
|
+
assetId: data?.fileId,
|
|
268
|
+
...(data?.versionId ? { versionId: data.versionId, version: data.version } : { version: 1 }),
|
|
269
|
+
upload: { method: 'PUT', url: data?.url, headers: { 'Content-Type': contentType } },
|
|
270
|
+
expiresAt: data?.expiresAt ?? null,
|
|
271
|
+
}),
|
|
272
|
+
}),
|
|
273
|
+
tool({
|
|
274
|
+
name: 'get_asset_download_url', title: 'Get a download URL', permissions: ['assets:read'],
|
|
275
|
+
description: "A short-lived signed URL to download an asset's file (its latest version, or the one given), for the tool that will use it (for example Blender's or Godot's MCP) to fetch with an HTTP GET before `expiresAt`. It works for that file only.",
|
|
276
|
+
input: { projectId, assetId: id('asset'), versionId: zod_1.z.string().optional().describe('A version (list_asset_versions); the latest if not given') },
|
|
277
|
+
projectOf: byProject,
|
|
278
|
+
call: ({ projectId, assetId, versionId }) => ({
|
|
279
|
+
method: 'GET',
|
|
280
|
+
path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/download-url${versionId ? `?${new URLSearchParams({ versionId })}` : ''}`,
|
|
281
|
+
}),
|
|
282
|
+
}),
|
|
283
|
+
tool({
|
|
284
|
+
name: 'list_deliverables', title: 'List deliverables', permissions: ['assets:read'],
|
|
285
|
+
description: "A project's deliverables (assets delivered for a shot or an export), of one shot or export if given: each one's asset, version, note and what it's for.",
|
|
286
|
+
input: {
|
|
287
|
+
projectId,
|
|
288
|
+
shotId: zod_1.z.string().optional().describe("A shot's id: only its deliverables"),
|
|
289
|
+
exportId: zod_1.z.string().optional().describe("An export's id: only its deliverables"),
|
|
290
|
+
},
|
|
291
|
+
projectOf: byProject,
|
|
292
|
+
call: ({ projectId, shotId, exportId }) => {
|
|
293
|
+
const query = new URLSearchParams(defined({ shotId, exportId })).toString();
|
|
294
|
+
return { method: 'GET', path: `/projects/${enc(projectId)}/deliverables${query ? `?${query}` : ''}` };
|
|
295
|
+
},
|
|
296
|
+
pick: (data) => data?.deliverables ?? [],
|
|
297
|
+
}),
|
|
298
|
+
tool({
|
|
299
|
+
name: 'mark_deliverable', title: 'Mark a deliverable', permissions: ['assets:write'], write: true,
|
|
300
|
+
description: "Marks an asset (or one of its versions) as a deliverable of a production shot or of an export. It shows in the shot's panel in the Production board, or in the export's Deliverables tab. Give shotId or exportId, one of them. Upload the file first (request_asset_upload).",
|
|
301
|
+
input: {
|
|
302
|
+
projectId,
|
|
303
|
+
assetId: id('asset'),
|
|
304
|
+
versionId: zod_1.z.string().optional().describe('A version of it; without one, the asset as it is (its latest version)'),
|
|
305
|
+
shotId: zod_1.z.string().optional().describe("The shot it's delivered for"),
|
|
306
|
+
exportId: zod_1.z.string().optional().describe("The export it's delivered for"),
|
|
307
|
+
note: zod_1.z.string().max(1000).optional(),
|
|
308
|
+
},
|
|
309
|
+
projectOf: byProject,
|
|
310
|
+
call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/deliverables`, body: defined(fields) }),
|
|
311
|
+
pick: (data) => data?.deliverable,
|
|
312
|
+
}),
|
|
313
|
+
tool({
|
|
314
|
+
name: 'remove_deliverable', title: 'Remove a deliverable', permissions: ['assets:write'], write: true,
|
|
315
|
+
description: 'Unmarks a deliverable. Its asset stays where it is.',
|
|
316
|
+
input: { projectId, deliverableId: id('deliverable') },
|
|
317
|
+
projectOf: byProject,
|
|
318
|
+
call: ({ projectId, deliverableId }) => ({ method: 'DELETE', path: `/projects/${enc(projectId)}/deliverables/${enc(deliverableId)}` }),
|
|
319
|
+
}),
|
|
227
320
|
// ── Projects ──
|
|
228
321
|
tool({
|
|
229
322
|
name: 'create_project', title: 'Create a project', permissions: ['projects:create'], write: true,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Auth } from '../types';
|
|
2
|
+
import type { ApiCaller } from './server';
|
|
3
|
+
export type WorkflowNodeRequirement = 'production';
|
|
4
|
+
/** A Shot node, as the UI makes one dropped on the canvas without a position. */
|
|
5
|
+
export declare const newShotNode: () => {
|
|
6
|
+
id: string;
|
|
7
|
+
type: string;
|
|
8
|
+
position: {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
};
|
|
12
|
+
data: {
|
|
13
|
+
label: string;
|
|
14
|
+
subtitle: string;
|
|
15
|
+
bgColor: string;
|
|
16
|
+
iconColor: string;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Makes sure a project's workflow has what `requirement` needs, adding it (through the REST
|
|
21
|
+
* API, as the caller) if it's missing. An error to answer with, or null to go on. A project
|
|
22
|
+
* the caller doesn't reach is left alone: the call that follows answers for it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ensureWorkflowNode(requirement: WorkflowNodeRequirement, projectId: string | undefined, auth: Auth, api: ApiCaller): Promise<string | null>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.newShotNode = void 0;
|
|
7
|
+
exports.ensureWorkflowNode = ensureWorkflowNode;
|
|
8
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
9
|
+
const manager_1 = require("../models/manager");
|
|
10
|
+
const auth_1 = require("../utils/auth");
|
|
11
|
+
const auth_2 = require("../services/auth");
|
|
12
|
+
// The node types that make a workflow's production section (as the UI groups them).
|
|
13
|
+
const PRODUCTION_NODE_TYPES = new Set(['shotNode', 'assemblyNode', 'masterNode']);
|
|
14
|
+
/** A Shot node, as the UI makes one dropped on the canvas without a position. */
|
|
15
|
+
const newShotNode = () => ({
|
|
16
|
+
id: `shotNode-${crypto_1.default.randomUUID()}`,
|
|
17
|
+
type: 'shotNode',
|
|
18
|
+
position: { x: 240 + Math.random() * 60, y: 300 + Math.random() * 40 },
|
|
19
|
+
data: { label: 'Shot', subtitle: 'Raw video clip', bgColor: '#0e2537', iconColor: '#38B6FF' },
|
|
20
|
+
});
|
|
21
|
+
exports.newShotNode = newShotNode;
|
|
22
|
+
/**
|
|
23
|
+
* Makes sure a project's workflow has what `requirement` needs, adding it (through the REST
|
|
24
|
+
* API, as the caller) if it's missing. An error to answer with, or null to go on. A project
|
|
25
|
+
* the caller doesn't reach is left alone: the call that follows answers for it.
|
|
26
|
+
*/
|
|
27
|
+
async function ensureWorkflowNode(requirement, projectId, auth, api) {
|
|
28
|
+
if (requirement !== 'production' || !projectId)
|
|
29
|
+
return null;
|
|
30
|
+
if (!(0, auth_2.scopeReachesProject)(auth.apiToken, projectId))
|
|
31
|
+
return null;
|
|
32
|
+
const project = await (0, manager_1.getModel)('projects').findOne({ _id: projectId });
|
|
33
|
+
if (!project || !(0, auth_1.activeMemberOf)(project, auth.userId))
|
|
34
|
+
return null;
|
|
35
|
+
const schema = project.workflow?.nodeSchema ?? null;
|
|
36
|
+
const nodes = Array.isArray(schema?.nodes) ? schema.nodes : [];
|
|
37
|
+
if (nodes.some(node => PRODUCTION_NODE_TYPES.has(node?.type)))
|
|
38
|
+
return null;
|
|
39
|
+
const answer = await api({
|
|
40
|
+
method: 'PATCH',
|
|
41
|
+
path: `/projects/${encodeURIComponent(projectId)}/workflow`,
|
|
42
|
+
body: { nodeSchema: { ...(schema ?? {}), nodes: [...nodes, (0, exports.newShotNode)()], edges: Array.isArray(schema?.edges) ? schema.edges : [] } },
|
|
43
|
+
});
|
|
44
|
+
if (answer.status >= 200 && answer.status < 300)
|
|
45
|
+
return null;
|
|
46
|
+
const message = answer.body?.message ?? answer.body?.error;
|
|
47
|
+
return `Couldn't add a production node to the project's workflow: ${typeof message === 'string' ? message : `StreamBy answered ${answer.status}`}`;
|
|
48
|
+
}
|
|
@@ -38,6 +38,7 @@ const production_1 = require("./routes/production");
|
|
|
38
38
|
const rights_1 = require("./routes/rights");
|
|
39
39
|
const workflow_1 = require("./routes/workflow");
|
|
40
40
|
const pipeline_1 = require("./routes/pipeline");
|
|
41
|
+
const deliverable_1 = require("./routes/deliverable");
|
|
41
42
|
function createStreamByRouter(config) {
|
|
42
43
|
const router = express_1.default.Router();
|
|
43
44
|
if (config.encrypt)
|
|
@@ -134,5 +135,6 @@ function createStreamByRouter(config) {
|
|
|
134
135
|
router.use((0, rights_1.rightsRouter)(config));
|
|
135
136
|
router.use((0, workflow_1.workflowRouter)(config));
|
|
136
137
|
router.use((0, pipeline_1.pipelineRouter)(config));
|
|
138
|
+
router.use((0, deliverable_1.deliverableRouter)(config));
|
|
137
139
|
return router;
|
|
138
140
|
}
|