creavit-studio-mcp 1.1.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.1.0
53
+ creavit-studio v1.2.0
54
54
 
55
- ✓ 46 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
- ✓ 52 bridge commands available
60
+ ✓ 56 bridge commands available
61
61
 
62
62
  Everything looks good.
63
63
  ```
@@ -121,8 +121,9 @@ files directly and work with the app closed.
121
121
  **Recording** — `creavit_devices_list`, `creavit_recording_status`,
122
122
  `creavit_recording_start`, `creavit_recording_stop`
123
123
 
124
- **Browser walkthrough** — `creavit_browser_open`, `creavit_browser_scroll`,
125
- `creavit_record_url_walkthrough`
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`
126
127
 
127
128
  **Escape hatches** — `creavit_capabilities`, `creavit_call`, `creavit_ipc`,
128
129
  `creavit_eval`, `creavit_logs`
@@ -163,13 +164,18 @@ timeline volume, so the saved project also exports without source audio.
163
164
 
164
165
  ### Record a URL walkthrough
165
166
 
166
- `creavit_record_url_walkthrough` is the high-level workflow. It opens the URL,
167
- selects that browser window, records a smooth section-by-section scroll, opens
168
- the generated project, adds scroll-highlight zooms, optionally covers the whole
169
- video with a perspective preset, mutes it, and saves it.
170
-
171
- When the calling agent has inspected the page, it can pass `sections` entries
172
- (`scrollPages`, `dwellMs`, `zoom`, `scale`, `x`, `y`) to dwell on and frame the
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
173
179
  important parts instead of using the generic evenly spaced scroll plan.
174
180
 
175
181
  Example input:
@@ -177,9 +183,15 @@ Example input:
177
183
  ```json
178
184
  {
179
185
  "url": "https://example.com",
180
- "browser": "Zen Browser",
181
- "scrollCount": 5,
182
- "scrollIntervalMs": 1800,
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,
183
195
  "autoZoom": true,
184
196
  "zoomScale": 1.8,
185
197
  "perspective": true,
@@ -208,8 +220,9 @@ Two things worth knowing:
208
220
  - Every request requires a bearer token, regenerated on each app launch.
209
221
  - The endpoint file is written with `0600` permissions.
210
222
  - Arbitrary code execution (`creavit_eval`) is **off by default**.
211
- - Browser scrolling uses macOS Accessibility. Enable Creavit Studio under
212
- **System Settings → Privacy & Security → Accessibility** when macOS asks.
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.
213
226
 
214
227
  ### Environment variables
215
228
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "creavit-studio-mcp",
3
- "version": "1.1.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.1.0";
9
+ export const SERVER_VERSION = "1.2.0";
10
10
 
11
11
  export const JSONRPC_ERRORS = {
12
12
  PARSE_ERROR: -32700,
@@ -1,4 +1,4 @@
1
- // MCP araçları — tarayıcı açma/scroll ve uçtan uca URL walkthrough kaydı.
1
+ // MCP tools — deterministic website walkthrough recording and browser control.
2
2
 
3
3
  import { bridgeTool, schema, S, textResult } from "./defineTool.mjs";
4
4
  import { callCommand } from "../bridgeClient.mjs";
@@ -29,31 +29,27 @@ function windowList(result) {
29
29
  }
30
30
 
31
31
  function searchableWindowText(windowInfo) {
32
- return [
33
- windowInfo?.appName,
34
- windowInfo?.ownerName,
35
- windowInfo?.name,
36
- windowInfo?.title,
37
- windowInfo?.windowTitle,
38
- ]
32
+ return [windowInfo?.appName, windowInfo?.ownerName, windowInfo?.name, windowInfo?.title, windowInfo?.windowTitle]
39
33
  .filter(Boolean)
40
34
  .join(" ")
41
35
  .toLowerCase();
42
36
  }
43
37
 
44
38
  function findBrowserWindow(windows, browserName, titleContains) {
45
- const browserNeedle = String(browserName || "").toLowerCase();
46
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
47
  const candidates = windows.filter((entry) => {
48
48
  const text = searchableWindowText(entry);
49
49
  return text.includes(browserNeedle) ||
50
50
  (browserNeedle === "google chrome" && text.includes("chrome")) ||
51
51
  (browserNeedle === "zen browser" && text.includes("zen"));
52
52
  });
53
- if (titleNeedle) {
54
- const titled = candidates.find((entry) => searchableWindowText(entry).includes(titleNeedle));
55
- if (titled) return titled;
56
- }
57
53
  return candidates.find((entry) => entry?.isOnScreen !== false && Number(entry?.id) > 0) ||
58
54
  candidates.find((entry) => Number(entry?.id) > 0) ||
59
55
  null;
@@ -71,29 +67,34 @@ async function waitForBrowserWindow(browserName, titleContains, timeoutMs) {
71
67
  }
72
68
  throw new Error(
73
69
  `Could not find a recordable ${browserName} window. Opened windows: ${lastWindows
74
- .map((entry) => searchableWindowText(entry))
75
- .filter(Boolean)
76
- .slice(0, 12)
77
- .join(" | ")}`,
70
+ .map((entry) => searchableWindowText(entry)).filter(Boolean).slice(0, 12).join(" | ")}`,
78
71
  );
79
72
  }
80
73
 
81
- async function waitForEditor(timeoutMs = 300_000) {
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) {
82
83
  const deadline = Date.now() + timeoutMs;
83
84
  let lastError = null;
84
85
  while (Date.now() < deadline) {
85
86
  try {
86
87
  const state = await callCommand("editor.getState", {}, 15_000);
87
- if (Number(state?.duration) > 0 && state?.playerMounted) return state;
88
+ const isNewProject = !previousProjectFilePath ||
89
+ (state?.projectFilePath && state.projectFilePath !== previousProjectFilePath);
90
+ if (Number(state?.duration) > 0 && state?.playerMounted && isNewProject) return state;
88
91
  } catch (error) {
89
92
  lastError = error;
90
93
  }
91
94
  await sleep(1000);
92
95
  }
93
96
  throw new Error(
94
- `Recording finished but the editor did not become ready within ${Math.round(timeoutMs / 1000)}s${
95
- lastError ? `: ${lastError.message}` : ""
96
- }`,
97
+ `Recording finished but the new project did not become ready within ${Math.round(timeoutMs / 1000)}s${lastError ? `: ${lastError.message}` : ""}`,
97
98
  );
98
99
  }
99
100
 
@@ -106,66 +107,139 @@ async function clearZooms() {
106
107
  return ranges.length;
107
108
  }
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
+
109
137
  export function browserTools() {
110
138
  return [
111
139
  bridgeTool({
112
140
  name: "creavit_browser_open",
113
141
  command: "browser.open",
114
142
  description:
115
- "Opens an http(s) URL in Zen Browser, Safari, Chrome, Arc, Firefox, Brave, or the default browser.",
143
+ "Opens an http(s) URL. Set cleanWindow=true for an isolated frameless capture window; it waits for DOM, fonts, and images before returning.",
116
144
  inputSchema: schema(
117
145
  {
118
146
  url: S.string("http(s) URL to open"),
119
- browser: S.string("Browser name (default: system browser)"),
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)"),
120
153
  },
121
154
  ["url"],
122
155
  ),
123
156
  }),
124
157
 
125
158
  bridgeTool({
126
- name: "creavit_browser_scroll",
127
- command: "browser.scroll",
128
- description:
129
- "Activates a browser and scrolls by page. Useful while recording. Requires macOS Accessibility permission for Creavit Studio.",
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.",
130
182
  inputSchema: schema(
131
183
  {
132
- browser: S.string("Zen Browser, Safari, Google Chrome, Arc, Firefox, or Brave Browser"),
133
- direction: S.string("Scroll direction", {
134
- enum: ["down", "up", "top", "bottom"],
135
- }),
136
- style: S.string("page or smooth (smooth uses small animated steps)", {
137
- enum: ["page", "smooth"],
138
- }),
139
- count: S.number("Number of page-key presses (1-50)"),
140
- intervalMs: S.number("Pause after each press in milliseconds"),
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)"),
141
190
  },
142
- ["browser"],
191
+ ["captureWindowId", "action"],
143
192
  ),
144
193
  }),
145
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
+
146
216
  {
147
217
  name: "creavit_record_url_walkthrough",
148
218
  description:
149
- "End-to-end website recording: opens a URL, finds that browser window, records it while scrolling, opens the result in Creavit Studio, optionally replaces zooms with automatic scroll-highlight zooms, applies a full-video perspective, and guarantees a muted export timeline. This operation can take several minutes.",
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.",
150
220
  inputSchema: schema(
151
221
  {
152
222
  url: S.string("http(s) page to record"),
153
- browser: S.string("Browser to use (default: Zen Browser)"),
154
- titleContains: S.string("Optional browser window title hint"),
155
- loadWaitMs: S.number("Wait for the page to load before recording (default 3000)"),
156
- leadInMs: S.number("Still time at the top before scrolling (default 1500)"),
157
- scrollCount: S.number("Number of Page Down scrolls (default 4, max 20)"),
158
- scrollIntervalMs: S.number("Time between scrolls / section dwell (default 1800)"),
159
- sections: S.array(
160
- "Optional agent-planned important sections. Each item: {scrollPages,dwellMs,zoom,scale,x,y}. Replaces scrollCount when present.",
161
- { type: "object" },
162
- ),
163
- tailMs: S.number("Still time after the last scroll (default 1500)"),
164
- mute: S.bool("Disable microphone/system audio and mute all export clips (default true)"),
165
- autoZoom: S.bool("Add zooms around scroll destinations (default true)"),
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)"),
166
240
  replaceZooms: S.bool("Remove recorder-created zooms first (default true)"),
167
241
  zoomScale: S.number("Automatic zoom scale (default 1.7)"),
168
- perspective: S.bool("Apply perspective to the complete result (default false)"),
242
+ perspective: S.bool("Apply perspective to the complete result"),
169
243
  perspectiveTemplate: S.string("Perspective template id (default minimal-tilt)"),
170
244
  save: S.bool("Save the generated project after editing (default true)"),
171
245
  },
@@ -173,163 +247,185 @@ export function browserTools() {
173
247
  ),
174
248
  run: async (args) => {
175
249
  const browser = canonicalBrowser(args.browser || "Zen Browser");
176
- const loadWaitMs = Math.max(500, Math.min(60_000, Number(args.loadWaitMs) || 3000));
177
- const leadInMs = Math.max(300, Math.min(30_000, Number(args.leadInMs) || 1500));
178
- const scrollCount = Math.max(
179
- 0,
180
- Math.min(20, Math.round(args.scrollCount === undefined ? 4 : Number(args.scrollCount))),
181
- );
182
- const scrollIntervalMs = Math.max(
183
- 400,
184
- Math.min(15_000, Number(args.scrollIntervalMs) || 1800),
185
- );
186
- const tailMs = Math.max(300, Math.min(30_000, Number(args.tailMs) || 1500));
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));
187
257
  const muted = args.mute !== false;
188
258
  const autoZoom = args.autoZoom !== false;
189
259
  const replaceZooms = args.replaceZooms !== false;
260
+ const controlSystemMouse = args.controlSystemMouse !== false;
190
261
  const zoomScale = Math.max(1, Math.min(5, Number(args.zoomScale) || 1.7));
191
- const sectionPlan = Array.isArray(args.sections) && args.sections.length
192
- ? args.sections.slice(0, 20).map((section) => {
193
- const scrollPages = Number(section?.scrollPages);
194
- const focusX = Number(section?.x);
195
- const focusY = Number(section?.y);
196
- return {
197
- scrollPages: Math.max(
198
- 0,
199
- Math.min(
200
- 5,
201
- Math.round(Number.isFinite(scrollPages) ? scrollPages : 1),
202
- ),
203
- ),
204
- dwellMs: Math.max(
205
- 400,
206
- Math.min(15_000, Number(section?.dwellMs) || scrollIntervalMs),
207
- ),
208
- zoom: section?.zoom !== false,
209
- scale: Math.max(
210
- 1,
211
- Math.min(5, Number(section?.scale) || zoomScale),
212
- ),
213
- x: Math.max(
214
- 0,
215
- Math.min(1, Number.isFinite(focusX) ? focusX : 0.5),
216
- ),
217
- y: Math.max(
218
- 0,
219
- Math.min(1, Number.isFinite(focusY) ? focusY : 0.5),
220
- ),
221
- };
222
- })
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
+ })
223
280
  : Array.from({ length: scrollCount }, (_, index) => ({
224
- scrollPages: 1,
225
- dwellMs: scrollIntervalMs,
226
- zoom: true,
227
- scale: zoomScale,
228
- x: index % 2 === 0 ? 0.42 : 0.58,
229
- y: 0.5,
230
- }));
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
+ }));
231
292
 
232
- await callCommand("browser.open", { url: args.url, browser }, 30_000);
233
- await sleep(loadWaitMs);
234
- await callCommand("browser.scroll", { browser, direction: "top", style: "page", count: 1, intervalMs: 100 });
235
- const browserWindow = await waitForBrowserWindow(browser, args.titleContains, 20_000);
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);
236
323
 
237
- await callCommand(
238
- "recording.start",
239
- {
324
+ await callCommand("recording.start", {
240
325
  options: {
241
326
  startScreen: true,
242
327
  cameraEnabled: false,
243
328
  micEnabled: !muted,
244
329
  systemAudioEnabled: !muted,
245
- recordingSource: {
246
- type: "window",
247
- windowId: browserWindow.id,
248
- windowInfo: browserWindow,
249
- },
330
+ recordingSource: { type: "window", windowId: browserWindow.id, windowInfo: browserWindow },
250
331
  },
251
- },
252
- 180_000,
253
- );
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())));
254
337
 
255
- const recordingStartedAt = Date.now();
256
- const zoomWindows = [];
257
- await sleep(leadInMs);
258
- for (let index = 0; index < sectionPlan.length; index += 1) {
259
- const section = sectionPlan[index];
260
- if (section.scrollPages > 0) {
261
- await callCommand(
262
- "browser.scroll",
263
- {
264
- browser,
265
- direction: "down",
266
- style: "smooth",
267
- count: section.scrollPages,
268
- intervalMs: 120,
269
- },
270
- 30_000,
271
- );
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);
272
373
  }
273
- const arrivedAt = (Date.now() - recordingStartedAt) / 1000;
274
- if (section.zoom) {
275
- zoomWindows.push({
276
- start: Math.max(0, arrivedAt + 0.12),
277
- end: Math.max(0.5, arrivedAt + section.dwellMs / 1000 - 0.12),
278
- scale: section.scale,
279
- x: section.x,
280
- y: section.y,
281
- });
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);
282
379
  }
283
- await sleep(section.dwellMs);
284
- }
285
- await sleep(tailMs);
286
- await callCommand("recording.stop", {}, 600_000);
380
+ await callCommand("recording.stop", {}, 600_000);
381
+ recordingStarted = false;
287
382
 
288
- const editorState = await waitForEditor(600_000);
289
- const applied = { muted: false, zooms: [], perspective: null, saved: false };
290
-
291
- if (muted) {
292
- await callCommand("editor.setMuted", { muted: true });
293
- applied.muted = true;
294
- }
295
- if (autoZoom) {
296
- if (replaceZooms) applied.removedZooms = await clearZooms();
297
- for (let index = 0; index < zoomWindows.length; index += 1) {
298
- const window = zoomWindows[index];
299
- const start = Math.min(window.start, Math.max(0, editorState.duration - 0.25));
300
- const end = Math.min(window.end, editorState.duration);
301
- if (end - start < 0.25) continue;
302
- const result = await callCommand("editor.addZoom", {
303
- start,
304
- end,
305
- scale: window.scale,
306
- x: window.x,
307
- y: window.y,
308
- });
309
- applied.zooms.push(result?.added || result);
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 (_) {}
310
427
  }
311
428
  }
312
- if (args.perspective === true) {
313
- const result = await callCommand("editor.addPerspective", {
314
- fullDuration: true,
315
- templateId: args.perspectiveTemplate || "minimal-tilt",
316
- });
317
- applied.perspective = result?.added || result;
318
- }
319
- if (args.save !== false) {
320
- await callCommand("editor.saveProject", {}, 180_000);
321
- applied.saved = true;
322
- }
323
-
324
- return textResult({
325
- ok: true,
326
- url: args.url,
327
- browser,
328
- window: browserWindow,
329
- duration: editorState.duration,
330
- projectFilePath: editorState.projectFilePath,
331
- applied,
332
- });
333
429
  },
334
430
  },
335
431
  ];