creavit-studio-mcp 1.0.0 → 1.2.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/README.md CHANGED
@@ -50,14 +50,14 @@ npx creavit-studio-mcp --doctor
50
50
  ```
51
51
 
52
52
  ```
53
- creavit-studio v1.0.0
53
+ creavit-studio v1.2.0
54
54
 
55
- 34 MCP tools defined
55
+ 50 MCP tools defined
56
56
  ✓ Offline project reading available (.crvt files readable without the app)
57
57
  ✓ Connected to the app (v3.0.5, pid 61068)
58
58
  Open windows: 2
59
59
  Agent-connected renderers: main, editor
60
- 41 bridge commands available
60
+ 56 bridge commands available
61
61
 
62
62
  Everything looks good.
63
63
  ```
@@ -110,9 +110,21 @@ files directly and work with the app closed.
110
110
  `creavit_editor_playback`, `creavit_editor_screenshot`,
111
111
  `creavit_editor_export`, `creavit_editor_history`
112
112
 
113
+ **Appearance** — `creavit_editor_background_get`,
114
+ `creavit_editor_background_set`, `creavit_editor_camera_get`,
115
+ `creavit_editor_camera_set`, `creavit_editor_audio`
116
+
117
+ **Perspective** — `creavit_editor_perspectives`,
118
+ `creavit_editor_add_perspective`, `creavit_editor_update_perspective`,
119
+ `creavit_editor_remove_perspective`
120
+
113
121
  **Recording** — `creavit_devices_list`, `creavit_recording_status`,
114
122
  `creavit_recording_start`, `creavit_recording_stop`
115
123
 
124
+ **Browser walkthrough** — `creavit_browser_open`, `creavit_browser_inspect`,
125
+ `creavit_browser_focus`, `creavit_browser_interact`, `creavit_browser_scroll`,
126
+ `creavit_browser_close`, `creavit_record_url_walkthrough`
127
+
116
128
  **Escape hatches** — `creavit_capabilities`, `creavit_call`, `creavit_ipc`,
117
129
  `creavit_eval`, `creavit_logs`
118
130
 
@@ -135,6 +147,63 @@ creavit_editor_screenshot VERIFY the result
135
147
  creavit_project_save persist
136
148
  ```
137
149
 
150
+ ### Background, camera, and mute
151
+
152
+ Use the typed tools instead of guessing generic setting keys:
153
+
154
+ ```text
155
+ creavit_editor_background_set type=solid color=#10131a
156
+ creavit_editor_background_set type=gradient gradient={type, direction, colors}
157
+ creavit_editor_camera_set settings={visible,size,radius,shadow,mirror,...}
158
+ creavit_editor_audio action=mute
159
+ creavit_project_save
160
+ ```
161
+
162
+ `creavit_editor_audio action=mute` changes both preview audio and every clip's
163
+ timeline volume, so the saved project also exports without source audio.
164
+
165
+ ### Record a URL walkthrough
166
+
167
+ `creavit_record_url_walkthrough` is the high-level workflow. By default it opens
168
+ the URL in a frameless site-only capture window (`16:9`, `4:3`, `1:1`, `9:16`,
169
+ or another requested ratio) and waits for the DOM, fonts, and images before
170
+ recording. It then executes ordered semantic actions with the real macOS mouse,
171
+ so the normal Creavit custom-cursor track receives authentic move/click events.
172
+ It trims the timeline to `durationMs`, adds
173
+ scroll-highlight zooms, optionally covers the whole video with a perspective
174
+ preset, mutes it, and saves it.
175
+
176
+ When the calling agent has inspected the page, it can pass ordered `steps`
177
+ (`scrollPages`, `dwellMs`, `zoom`, `scale`, `x`, `y`, and optionally
178
+ `selector`/`text` plus `action`) to dwell on, hover, click, type, and frame the
179
+ important parts instead of using the generic evenly spaced scroll plan.
180
+
181
+ Example input:
182
+
183
+ ```json
184
+ {
185
+ "url": "https://example.com",
186
+ "cleanWindow": true,
187
+ "aspectRatio": "4:3",
188
+ "durationMs": 10000,
189
+ "steps": [
190
+ { "action": "click", "text": "Features", "dwellMs": 1000 },
191
+ { "action": "scrollTo", "text": "Get Started", "dwellMs": 700 },
192
+ { "action": "click", "text": "Get Started", "dwellMs": 3000 }
193
+ ],
194
+ "controlSystemMouse": true,
195
+ "autoZoom": true,
196
+ "zoomScale": 1.8,
197
+ "perspective": true,
198
+ "perspectiveTemplate": "minimal-tilt",
199
+ "mute": true
200
+ }
201
+ ```
202
+
203
+ For hand-directed recordings, use `creavit_browser_open`,
204
+ `creavit_recording_start`, and `creavit_browser_scroll` separately, then add
205
+ zooms/perspective with the editor tools.
206
+
138
207
  Two things worth knowing:
139
208
 
140
209
  - **Wait for the load.** After `creavit_project_open`, poll `creavit_editor_state`
@@ -151,6 +220,9 @@ Two things worth knowing:
151
220
  - Every request requires a bearer token, regenerated on each app launch.
152
221
  - The endpoint file is written with `0600` permissions.
153
222
  - Arbitrary code execution (`creavit_eval`) is **off by default**.
223
+ - Page loading and scrolling inside a clean capture window do not require
224
+ Accessibility permission. Real system mouse move/click automation and
225
+ external-browser scrolling do require macOS Accessibility permission.
154
226
 
155
227
  ### Environment variables
156
228
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "creavit-studio-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server that lets AI coding agents (Claude Code, Codex, Cursor) drive the Creavit Studio screen recording app",
5
5
  "keywords": [
6
6
  "mcp",
package/src/protocol.mjs CHANGED
@@ -6,7 +6,7 @@ export const ENDPOINT_FILENAME = "agent-bridge.json";
6
6
 
7
7
  export const MCP_PROTOCOL_VERSION = "2024-11-05";
8
8
  export const SERVER_NAME = "creavit-studio";
9
- export const SERVER_VERSION = "1.0.0";
9
+ export const SERVER_VERSION = "1.2.0";
10
10
 
11
11
  export const JSONRPC_ERRORS = {
12
12
  PARSE_ERROR: -32700,
@@ -0,0 +1,432 @@
1
+ // MCP tools — deterministic website walkthrough recording and browser control.
2
+
3
+ import { bridgeTool, schema, S, textResult } from "./defineTool.mjs";
4
+ import { callCommand } from "../bridgeClient.mjs";
5
+
6
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7
+
8
+ const BROWSER_APP_NAMES = {
9
+ zen: "Zen Browser",
10
+ "zen browser": "Zen Browser",
11
+ safari: "Safari",
12
+ chrome: "Google Chrome",
13
+ "google chrome": "Google Chrome",
14
+ arc: "Arc",
15
+ firefox: "Firefox",
16
+ brave: "Brave Browser",
17
+ "brave browser": "Brave Browser",
18
+ };
19
+
20
+ function canonicalBrowser(value) {
21
+ const key = String(value || "zen").trim().toLowerCase();
22
+ return BROWSER_APP_NAMES[key] || value;
23
+ }
24
+
25
+ function windowList(result) {
26
+ if (Array.isArray(result)) return result;
27
+ if (Array.isArray(result?.windows)) return result.windows;
28
+ return [];
29
+ }
30
+
31
+ function searchableWindowText(windowInfo) {
32
+ return [windowInfo?.appName, windowInfo?.ownerName, windowInfo?.name, windowInfo?.title, windowInfo?.windowTitle]
33
+ .filter(Boolean)
34
+ .join(" ")
35
+ .toLowerCase();
36
+ }
37
+
38
+ function findBrowserWindow(windows, browserName, titleContains) {
39
+ const titleNeedle = String(titleContains || "").trim().toLowerCase();
40
+ if (titleNeedle) {
41
+ const exactCapture = windows.find(
42
+ (entry) => Number(entry?.id) > 0 && searchableWindowText(entry).includes(titleNeedle),
43
+ );
44
+ if (exactCapture) return exactCapture;
45
+ }
46
+ const browserNeedle = String(browserName || "").toLowerCase();
47
+ const candidates = windows.filter((entry) => {
48
+ const text = searchableWindowText(entry);
49
+ return text.includes(browserNeedle) ||
50
+ (browserNeedle === "google chrome" && text.includes("chrome")) ||
51
+ (browserNeedle === "zen browser" && text.includes("zen"));
52
+ });
53
+ return candidates.find((entry) => entry?.isOnScreen !== false && Number(entry?.id) > 0) ||
54
+ candidates.find((entry) => Number(entry?.id) > 0) ||
55
+ null;
56
+ }
57
+
58
+ async function waitForBrowserWindow(browserName, titleContains, timeoutMs) {
59
+ const deadline = Date.now() + timeoutMs;
60
+ let lastWindows = [];
61
+ while (Date.now() < deadline) {
62
+ const devices = await callCommand("devices.list", { kinds: ["windows"] }, 60_000);
63
+ lastWindows = windowList(devices?.windows);
64
+ const found = findBrowserWindow(lastWindows, browserName, titleContains);
65
+ if (found) return found;
66
+ await sleep(500);
67
+ }
68
+ throw new Error(
69
+ `Could not find a recordable ${browserName} window. Opened windows: ${lastWindows
70
+ .map((entry) => searchableWindowText(entry)).filter(Boolean).slice(0, 12).join(" | ")}`,
71
+ );
72
+ }
73
+
74
+ async function readEditorState() {
75
+ try {
76
+ return await callCommand("editor.getState", {}, 15_000);
77
+ } catch (_) {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ async function waitForEditor(previousProjectFilePath, timeoutMs = 300_000) {
83
+ const deadline = Date.now() + timeoutMs;
84
+ let lastError = null;
85
+ while (Date.now() < deadline) {
86
+ try {
87
+ const state = await callCommand("editor.getState", {}, 15_000);
88
+ const isNewProject = !previousProjectFilePath ||
89
+ (state?.projectFilePath && state.projectFilePath !== previousProjectFilePath);
90
+ if (Number(state?.duration) > 0 && state?.playerMounted && isNewProject) return state;
91
+ } catch (error) {
92
+ lastError = error;
93
+ }
94
+ await sleep(1000);
95
+ }
96
+ throw new Error(
97
+ `Recording finished but the new project did not become ready within ${Math.round(timeoutMs / 1000)}s${lastError ? `: ${lastError.message}` : ""}`,
98
+ );
99
+ }
100
+
101
+ async function clearZooms() {
102
+ const result = await callCommand("editor.listZooms", {});
103
+ const ranges = Array.isArray(result?.zoomRanges) ? result.zoomRanges : [];
104
+ for (let index = ranges.length - 1; index >= 0; index -= 1) {
105
+ await callCommand("editor.removeZoom", { index });
106
+ }
107
+ return ranges.length;
108
+ }
109
+
110
+ function trimSegmentsToDuration(segments, targetDuration) {
111
+ return segments
112
+ .map((segment) => {
113
+ const timelineStart = Number(segment.timelineStart ?? segment.start ?? segment.startTime ?? 0);
114
+ const timelineEnd = Number(segment.timelineEnd ?? segment.end ?? segment.endTime ?? timelineStart);
115
+ if (!Number.isFinite(timelineStart) || timelineStart >= targetDuration || timelineEnd <= timelineStart) return null;
116
+ if (timelineEnd <= targetDuration) return segment;
117
+ const clippedTimelineEnd = targetDuration;
118
+ const timelineSpan = Math.max(0.0001, timelineEnd - timelineStart);
119
+ const videoStart = Number(segment.videoStart ?? segment.startTime ?? 0);
120
+ const videoEnd = Number(segment.videoEnd ?? segment.endTime ?? videoStart + timelineSpan);
121
+ const ratio = Math.max(0, videoEnd - videoStart) / timelineSpan;
122
+ const clippedVideoEnd = videoStart + (clippedTimelineEnd - timelineStart) * ratio;
123
+ return {
124
+ ...segment,
125
+ timelineEnd: clippedTimelineEnd,
126
+ end: clippedTimelineEnd,
127
+ duration: clippedTimelineEnd - timelineStart,
128
+ videoEnd: clippedVideoEnd,
129
+ videoDuration: clippedVideoEnd - videoStart,
130
+ endTime: clippedVideoEnd,
131
+ sourceVideoEnd: clippedVideoEnd,
132
+ };
133
+ })
134
+ .filter(Boolean);
135
+ }
136
+
137
+ export function browserTools() {
138
+ return [
139
+ bridgeTool({
140
+ name: "creavit_browser_open",
141
+ command: "browser.open",
142
+ description:
143
+ "Opens an http(s) URL. Set cleanWindow=true for an isolated frameless capture window; it waits for DOM, fonts, and images before returning.",
144
+ inputSchema: schema(
145
+ {
146
+ url: S.string("http(s) URL to open"),
147
+ browser: S.string("External browser name when cleanWindow is false"),
148
+ cleanWindow: S.bool("Open an isolated site-only capture window"),
149
+ aspectRatio: S.string("Capture ratio such as 16:9, 4:3, 1:1, or 9:16 (default 16:9)"),
150
+ width: S.number("Optional capture content width; height is derived from aspectRatio"),
151
+ height: S.number("Optional capture content height when width is omitted"),
152
+ loadWaitMs: S.number("Extra settle time after DOM/fonts/images load (default 1200)"),
153
+ },
154
+ ["url"],
155
+ ),
156
+ }),
157
+
158
+ bridgeTool({
159
+ name: "creavit_browser_inspect",
160
+ command: "browser.inspect",
161
+ description: "Lists headings and interactive elements in a clean capture window so an agent can choose meaningful focus/click targets.",
162
+ inputSchema: schema(
163
+ { captureWindowId: S.number("ID returned by creavit_browser_open"), limit: S.number("Maximum elements (default 80)") },
164
+ ["captureWindowId"],
165
+ ),
166
+ }),
167
+
168
+ bridgeTool({
169
+ name: "creavit_browser_focus",
170
+ command: "browser.focusPoint",
171
+ description: "Smoothly moves the real macOS system mouse to a normalized page point, so Creavit records its native custom-cursor data.",
172
+ inputSchema: schema(
173
+ { captureWindowId: S.number("Capture window ID"), x: S.number("Horizontal position, 0..1"), y: S.number("Vertical position, 0..1"), durationMs: S.number("Mouse travel duration (default 520)"), click: S.bool("Perform a real left click after moving") },
174
+ ["captureWindowId", "x", "y"],
175
+ ),
176
+ }),
177
+
178
+ bridgeTool({
179
+ name: "creavit_browser_interact",
180
+ command: "browser.interact",
181
+ description: "Finds a page element by CSS selector or visible text, scrolls to it, moves the real system mouse, and performs a real hover/click/type action.",
182
+ inputSchema: schema(
183
+ {
184
+ captureWindowId: S.number("Capture window ID"),
185
+ action: S.string("Interaction", { enum: ["hover", "click", "type", "scrollIntoView", "scrollTo"] }),
186
+ selector: S.string("CSS selector"),
187
+ text: S.string("Visible text fallback"),
188
+ value: S.string("Text to enter for type"),
189
+ moveDurationMs: S.number("Real mouse travel duration (default 520)"),
190
+ },
191
+ ["captureWindowId", "action"],
192
+ ),
193
+ }),
194
+
195
+ bridgeTool({
196
+ name: "creavit_browser_scroll",
197
+ command: "browser.scroll",
198
+ description: "Scrolls a clean capture window directly, or activates and scrolls an external browser.",
199
+ inputSchema: schema({
200
+ captureWindowId: S.number("Clean capture window ID; preferred over browser"),
201
+ browser: S.string("External browser name"),
202
+ direction: S.string("Scroll direction", { enum: ["down", "up", "top", "bottom"] }),
203
+ style: S.string("page or smooth", { enum: ["page", "smooth"] }),
204
+ count: S.number("Number of scroll steps (1-50)"),
205
+ intervalMs: S.number("Time for each scroll step in milliseconds"),
206
+ }),
207
+ }),
208
+
209
+ bridgeTool({
210
+ name: "creavit_browser_close",
211
+ command: "browser.close",
212
+ description: "Closes an isolated clean capture window.",
213
+ inputSchema: schema({ captureWindowId: S.number("Capture window ID") }, ["captureWindowId"]),
214
+ }),
215
+
216
+ {
217
+ name: "creavit_record_url_walkthrough",
218
+ description:
219
+ "Records a site in a clean configurable-ratio window after real page readiness. It can execute ordered semantic steps using the real system mouse, trims to an exact duration, adds zooms, optional perspective, and optionally mutes it.",
220
+ inputSchema: schema(
221
+ {
222
+ url: S.string("http(s) page to record"),
223
+ browser: S.string("External browser fallback (default Zen Browser)"),
224
+ cleanWindow: S.bool("Use a frameless site-only capture window (default true)"),
225
+ aspectRatio: S.string("Capture ratio: 16:9, 4:3, 1:1, 9:16, or another W:H value"),
226
+ width: S.number("Optional capture content width; height is derived from aspectRatio"),
227
+ height: S.number("Optional capture content height when width is omitted"),
228
+ titleContains: S.string("Optional native window title hint"),
229
+ loadWaitMs: S.number("Extra settle time after real page readiness (default 1500)"),
230
+ durationMs: S.number("Exact final timeline duration in milliseconds"),
231
+ leadInMs: S.number("Still time before scrolling (default 1000)"),
232
+ scrollCount: S.number("Number of smooth page scrolls (default 4, max 20)"),
233
+ scrollIntervalMs: S.number("Dwell after each section (default 1400)"),
234
+ steps: S.array("Ordered actions: {action:'click|hover|scrollTo|scroll|wait|type',selector?,text?,value?,scrollPages?,dwellMs?,zoom?,scale?,x?,y?}", { type: "object" }),
235
+ sections: S.array("Deprecated alias for steps", { type: "object" }),
236
+ tailMs: S.number("Still time after the last scroll (default 800)"),
237
+ controlSystemMouse: S.bool("Move/click the real macOS mouse so Creavit records native cursor data (default true)"),
238
+ mute: S.bool("Disable recording audio and mute export clips (default true)"),
239
+ autoZoom: S.bool("Add zooms around section destinations (default true)"),
240
+ replaceZooms: S.bool("Remove recorder-created zooms first (default true)"),
241
+ zoomScale: S.number("Automatic zoom scale (default 1.7)"),
242
+ perspective: S.bool("Apply perspective to the complete result"),
243
+ perspectiveTemplate: S.string("Perspective template id (default minimal-tilt)"),
244
+ save: S.bool("Save the generated project after editing (default true)"),
245
+ },
246
+ ["url"],
247
+ ),
248
+ run: async (args) => {
249
+ const browser = canonicalBrowser(args.browser || "Zen Browser");
250
+ const cleanWindow = args.cleanWindow !== false;
251
+ const loadWaitMs = Math.max(0, Math.min(60_000, Number(args.loadWaitMs) || 1500));
252
+ const durationMs = args.durationMs === undefined ? null : Math.max(1000, Math.min(3_600_000, Number(args.durationMs) || 10_000));
253
+ const leadInMs = Math.max(200, Math.min(30_000, Number(args.leadInMs) || 1000));
254
+ const scrollCount = Math.max(0, Math.min(20, Math.round(args.scrollCount === undefined ? 4 : Number(args.scrollCount))));
255
+ const scrollIntervalMs = Math.max(300, Math.min(15_000, Number(args.scrollIntervalMs) || 1400));
256
+ const tailMs = Math.max(200, Math.min(30_000, Number(args.tailMs) || 800));
257
+ const muted = args.mute !== false;
258
+ const autoZoom = args.autoZoom !== false;
259
+ const replaceZooms = args.replaceZooms !== false;
260
+ const controlSystemMouse = args.controlSystemMouse !== false;
261
+ const zoomScale = Math.max(1, Math.min(5, Number(args.zoomScale) || 1.7));
262
+ const requestedSteps = Array.isArray(args.steps) && args.steps.length ? args.steps : args.sections;
263
+ const sectionPlan = Array.isArray(requestedSteps) && requestedSteps.length
264
+ ? requestedSteps.slice(0, 30).map((section) => {
265
+ const action = section?.action || (section?.selector || section?.text ? "hover" : "scroll");
266
+ const defaultScrollPages = action === "scroll" ? 1 : 0;
267
+ return {
268
+ action,
269
+ scrollPages: Math.max(0, Math.min(5, Math.round(Number.isFinite(Number(section?.scrollPages)) ? Number(section.scrollPages) : defaultScrollPages))),
270
+ dwellMs: Math.max(300, Math.min(15_000, Number(section?.dwellMs) || scrollIntervalMs)),
271
+ zoom: section?.zoom !== false,
272
+ scale: Math.max(1, Math.min(5, Number(section?.scale) || zoomScale)),
273
+ x: Math.max(0.01, Math.min(0.99, Number.isFinite(Number(section?.x)) ? Number(section.x) : 0.5)),
274
+ y: Math.max(0.01, Math.min(0.99, Number.isFinite(Number(section?.y)) ? Number(section.y) : 0.48)),
275
+ selector: section?.selector || null,
276
+ text: section?.text || null,
277
+ value: section?.value || "",
278
+ };
279
+ })
280
+ : Array.from({ length: scrollCount }, (_, index) => ({
281
+ scrollPages: 1,
282
+ dwellMs: scrollIntervalMs,
283
+ zoom: true,
284
+ scale: zoomScale,
285
+ x: index % 2 === 0 ? 0.42 : 0.58,
286
+ y: 0.48,
287
+ selector: null,
288
+ text: null,
289
+ action: "hover",
290
+ value: "",
291
+ }));
292
+
293
+ const previousEditor = await readEditorState();
294
+ let captureWindowId = null;
295
+ let recordingStarted = false;
296
+ let openResult;
297
+ const zoomWindows = [];
298
+ try {
299
+ openResult = await callCommand("browser.open", {
300
+ url: args.url,
301
+ browser,
302
+ cleanWindow,
303
+ aspectRatio: args.aspectRatio || "16:9",
304
+ width: Number.isFinite(Number(args.width)) ? Number(args.width) : undefined,
305
+ height: Number.isFinite(Number(args.height)) ? Number(args.height) : undefined,
306
+ loadWaitMs,
307
+ }, 90_000);
308
+ captureWindowId = openResult?.captureWindowId ?? null;
309
+ const titleHint = openResult?.windowTitle || args.titleContains;
310
+ if (!cleanWindow) await sleep(loadWaitMs);
311
+ await callCommand("browser.scroll", {
312
+ captureWindowId,
313
+ browser: captureWindowId ? undefined : browser,
314
+ direction: "top",
315
+ style: "page",
316
+ count: 1,
317
+ intervalMs: 150,
318
+ });
319
+ if (captureWindowId && controlSystemMouse) {
320
+ await callCommand("browser.focusPoint", { captureWindowId, x: 0.5, y: 0.46 });
321
+ }
322
+ const browserWindow = await waitForBrowserWindow(captureWindowId ? "Creavit Studio" : browser, titleHint, 20_000);
323
+
324
+ await callCommand("recording.start", {
325
+ options: {
326
+ startScreen: true,
327
+ cameraEnabled: false,
328
+ micEnabled: !muted,
329
+ systemAudioEnabled: !muted,
330
+ recordingSource: { type: "window", windowId: browserWindow.id, windowInfo: browserWindow },
331
+ },
332
+ }, 180_000);
333
+ recordingStarted = true;
334
+ const recordingStartedAt = Date.now();
335
+ const contentDeadline = durationMs ? recordingStartedAt + durationMs - tailMs : Number.POSITIVE_INFINITY;
336
+ await sleep(Math.min(leadInMs, Math.max(0, contentDeadline - Date.now())));
337
+
338
+ for (const section of sectionPlan) {
339
+ if (Date.now() >= contentDeadline) break;
340
+ let focus = { x: section.x, y: section.y };
341
+ if (section.action === "wait") {
342
+ // Dwell-only step; the current page and cursor stay untouched.
343
+ } else if (captureWindowId && (section.selector || section.text)) {
344
+ const interaction = await callCommand("browser.interact", {
345
+ captureWindowId,
346
+ action: section.action === "scrollTo" ? "scrollTo" : section.action,
347
+ selector: section.selector,
348
+ text: section.text,
349
+ value: section.value,
350
+ }, 30_000);
351
+ if (Number.isFinite(interaction?.x)) focus = { x: interaction.x, y: interaction.y };
352
+ } else {
353
+ if (section.scrollPages > 0) {
354
+ await callCommand("browser.scroll", {
355
+ captureWindowId,
356
+ browser: captureWindowId ? undefined : browser,
357
+ direction: "down",
358
+ style: "smooth",
359
+ count: section.scrollPages,
360
+ intervalMs: 650,
361
+ }, 30_000);
362
+ }
363
+ if (captureWindowId && controlSystemMouse) {
364
+ await callCommand("browser.focusPoint", { captureWindowId, x: focus.x, y: focus.y });
365
+ }
366
+ }
367
+ const arrivedAt = (Date.now() - recordingStartedAt) / 1000;
368
+ const dwell = Math.min(section.dwellMs, Math.max(0, contentDeadline - Date.now()));
369
+ if (section.zoom && dwell >= 250) {
370
+ zoomWindows.push({ start: Math.max(0, arrivedAt + 0.08), end: Math.max(arrivedAt + 0.3, arrivedAt + dwell / 1000 - 0.08), scale: section.scale, x: focus.x, y: focus.y });
371
+ }
372
+ if (dwell > 0) await sleep(dwell);
373
+ }
374
+ if (durationMs) {
375
+ const stopAt = recordingStartedAt + durationMs + 1500;
376
+ if (Date.now() < stopAt) await sleep(stopAt - Date.now());
377
+ } else {
378
+ await sleep(tailMs);
379
+ }
380
+ await callCommand("recording.stop", {}, 600_000);
381
+ recordingStarted = false;
382
+
383
+ const editorState = await waitForEditor(previousEditor?.projectFilePath, 600_000);
384
+ const applied = { muted: false, zooms: [], perspective: null, saved: false, trimmedTo: null };
385
+ let finalDuration = Number(editorState.duration);
386
+ if (durationMs) {
387
+ const targetDuration = durationMs / 1000;
388
+ const listed = await callCommand("editor.listSegments", {});
389
+ const segments = Array.isArray(listed?.segments) ? listed.segments : [];
390
+ const trimmed = trimSegmentsToDuration(segments, targetDuration);
391
+ if (!trimmed.length || finalDuration + 0.02 < targetDuration) {
392
+ throw new Error(`Recording was too short for the requested ${targetDuration}s timeline (captured ${finalDuration}s)`);
393
+ }
394
+ await callCommand("editor.setSegments", { segments: trimmed });
395
+ finalDuration = targetDuration;
396
+ applied.trimmedTo = targetDuration;
397
+ }
398
+ if (muted) {
399
+ await callCommand("editor.setMuted", { muted: true });
400
+ applied.muted = true;
401
+ }
402
+ if (autoZoom) {
403
+ if (replaceZooms) applied.removedZooms = await clearZooms();
404
+ for (const zoomWindow of zoomWindows) {
405
+ const start = Math.min(zoomWindow.start, Math.max(0, finalDuration - 0.25));
406
+ const end = Math.min(zoomWindow.end, finalDuration);
407
+ if (end - start < 0.25) continue;
408
+ const result = await callCommand("editor.addZoom", { start, end, scale: zoomWindow.scale, x: zoomWindow.x, y: zoomWindow.y });
409
+ applied.zooms.push(result?.added || result);
410
+ }
411
+ }
412
+ if (args.perspective === true) {
413
+ const result = await callCommand("editor.addPerspective", { fullDuration: true, templateId: args.perspectiveTemplate || "minimal-tilt" });
414
+ applied.perspective = result?.added || result;
415
+ }
416
+ if (args.save !== false) {
417
+ await callCommand("editor.saveProject", {}, 180_000);
418
+ applied.saved = true;
419
+ }
420
+ return textResult({ ok: true, url: args.url, browser: cleanWindow ? "Creavit Capture" : browser, cleanWindow, page: openResult, window: browserWindow, duration: finalDuration, rawDuration: editorState.duration, projectFilePath: editorState.projectFilePath, applied });
421
+ } finally {
422
+ if (recordingStarted) {
423
+ try { await callCommand("recording.stop", {}, 600_000); } catch (_) {}
424
+ }
425
+ if (captureWindowId) {
426
+ try { await callCommand("browser.close", { captureWindowId }); } catch (_) {}
427
+ }
428
+ }
429
+ },
430
+ },
431
+ ];
432
+ }
@@ -49,6 +49,81 @@ export function editorTools() {
49
49
  }),
50
50
  }),
51
51
 
52
+ bridgeTool({
53
+ name: "creavit_editor_background_get",
54
+ command: "editor.getBackground",
55
+ description:
56
+ "Reads the complete canvas background configuration: solid color, gradient, image, blur, and dynamic pattern values.",
57
+ inputSchema: schema(),
58
+ }),
59
+
60
+ bridgeTool({
61
+ name: "creavit_editor_background_set",
62
+ command: "editor.setBackground",
63
+ description:
64
+ "Sets the canvas background. For solid use type='solid' and color. For gradient pass at least two stops as {type:'linear'|'radial', direction:'to-right', colors:[{color:'#...',position:0},...]}. Call creavit_project_save afterwards.",
65
+ inputSchema: schema(
66
+ {
67
+ type: S.string("Background type", {
68
+ enum: ["solid", "color", "gradient", "image", "dynamic"],
69
+ }),
70
+ color: S.string("CSS color for a solid background"),
71
+ image: S.string("Image URL or local path"),
72
+ blur: S.number("Image blur amount"),
73
+ gradient: S.object(
74
+ "Gradient configuration: {type, direction, colors:[{color,position}]}.",
75
+ ),
76
+ dynamic: S.object("Dynamic pattern configuration"),
77
+ },
78
+ ["type"],
79
+ ),
80
+ mapResult: (result) =>
81
+ textResult({ ...result, note: "Applied. Call creavit_project_save to persist." }),
82
+ }),
83
+
84
+ bridgeTool({
85
+ name: "creavit_editor_camera_get",
86
+ command: "editor.getCamera",
87
+ description: "Reads all camera overlay settings.",
88
+ inputSchema: schema(),
89
+ }),
90
+
91
+ bridgeTool({
92
+ name: "creavit_editor_camera_set",
93
+ command: "editor.setCamera",
94
+ description:
95
+ "Partially updates camera overlay settings: visible, size, radius, shadow, position crop, cropZoom, followMouse, oscillationMovement, mirror, aspectRatio, border, background removal, mergeWithCursor, lockSizeOnZoom and sizeOnZoom. Call creavit_project_save afterwards.",
96
+ inputSchema: schema(
97
+ { settings: S.object("Camera settings to update") },
98
+ ["settings"],
99
+ ),
100
+ mapResult: (result) =>
101
+ textResult({ ...result, note: "Applied. Call creavit_project_save to persist." }),
102
+ }),
103
+
104
+ {
105
+ name: "creavit_editor_audio",
106
+ description:
107
+ "Reads audio state or mutes/unmutes both preview and every timeline clip used by export. Use action='mute' to guarantee that the exported video has no source audio.",
108
+ inputSchema: schema(
109
+ {
110
+ action: S.string("status, mute, or unmute", {
111
+ enum: ["status", "mute", "unmute"],
112
+ }),
113
+ },
114
+ ["action"],
115
+ ),
116
+ run: async ({ action }) => {
117
+ if (action === "status") return textResult(await callCommand("editor.getAudio", {}));
118
+ if (action !== "mute" && action !== "unmute") {
119
+ throw new Error("`action` must be status, mute, or unmute");
120
+ }
121
+ return textResult(
122
+ await callCommand("editor.setMuted", { muted: action === "mute" }),
123
+ );
124
+ },
125
+ },
126
+
52
127
  bridgeTool({
53
128
  name: "creavit_editor_list_zooms",
54
129
  command: "editor.listZooms",
@@ -99,6 +174,52 @@ export function editorTools() {
99
174
  }),
100
175
  }),
101
176
 
177
+ bridgeTool({
178
+ name: "creavit_editor_perspectives",
179
+ command: "editor.listPerspectives",
180
+ description: "Lists all perspective (3D animation) ranges on the timeline.",
181
+ inputSchema: schema(),
182
+ }),
183
+
184
+ bridgeTool({
185
+ name: "creavit_editor_add_perspective",
186
+ command: "editor.addPerspective",
187
+ description:
188
+ "Adds a Creavit perspective range. Set fullDuration=true to cover the complete video. Templates include slant-left, slant-right, hero, lift, corner-left, corner-right, floating, perspective-left, perspective-right, minimal-tilt and cinematic-wide.",
189
+ inputSchema: schema({
190
+ start: S.number("Start time in seconds"),
191
+ end: S.number("End time in seconds"),
192
+ fullDuration: S.bool("Cover the complete video, ignoring start/end"),
193
+ templateId: S.string("Perspective template id"),
194
+ settings: S.object("Optional overrides for the perspective range"),
195
+ }),
196
+ }),
197
+
198
+ bridgeTool({
199
+ name: "creavit_editor_update_perspective",
200
+ command: "editor.updatePerspective",
201
+ description: "Updates an existing perspective range by id or zero-based index.",
202
+ inputSchema: schema(
203
+ {
204
+ id: S.string("Perspective range ID"),
205
+ index: S.number("Perspective range index"),
206
+ patch: S.object("Fields to update"),
207
+ },
208
+ ["patch"],
209
+ ),
210
+ }),
211
+
212
+ bridgeTool({
213
+ name: "creavit_editor_remove_perspective",
214
+ command: "editor.removePerspective",
215
+ description: "Deletes a perspective range by id/index, or all perspective ranges with all=true.",
216
+ inputSchema: schema({
217
+ id: S.string("Perspective range ID"),
218
+ index: S.number("Perspective range index"),
219
+ all: S.bool("Delete every perspective range"),
220
+ }),
221
+ }),
222
+
102
223
  bridgeTool({
103
224
  name: "creavit_editor_segments",
104
225
  command: "editor.listSegments",
@@ -3,6 +3,7 @@
3
3
  import { projectTools } from "./projectTools.mjs";
4
4
  import { editorTools } from "./editorTools.mjs";
5
5
  import { systemTools } from "./systemTools.mjs";
6
+ import { browserTools } from "./browserTools.mjs";
6
7
  import { escapeTools } from "./escapeTools.mjs";
7
8
  import { errorResult } from "./defineTool.mjs";
8
9
  import { BridgeUnavailableError } from "../bridgeClient.mjs";
@@ -11,7 +12,13 @@ let registry = null;
11
12
 
12
13
  function buildRegistry() {
13
14
  if (registry) return registry;
14
- const all = [...systemTools(), ...projectTools(), ...editorTools(), ...escapeTools()];
15
+ const all = [
16
+ ...systemTools(),
17
+ ...projectTools(),
18
+ ...editorTools(),
19
+ ...browserTools(),
20
+ ...escapeTools(),
21
+ ];
15
22
 
16
23
  const byName = new Map();
17
24
  for (const tool of all) {