@lightcone-ai/daemon 0.14.15 → 0.14.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,424 @@
1
+ function normalizeText(value) {
2
+ if (typeof value !== 'string') return '';
3
+ return value.trim();
4
+ }
5
+
6
+ function normalizeInteger(value, fallback = null) {
7
+ const parsed = Number.parseInt(String(value ?? ''), 10);
8
+ if (!Number.isFinite(parsed)) return fallback;
9
+ return parsed;
10
+ }
11
+
12
+ function normalizeRange(value) {
13
+ if (!Array.isArray(value) || value.length !== 2) return null;
14
+ const start = Number(value[0]);
15
+ const end = Number(value[1]);
16
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
17
+ const low = Math.round(Math.min(start, end));
18
+ const high = Math.round(Math.max(start, end));
19
+ return [low, high];
20
+ }
21
+
22
+ function inferActionFromCameraMotion(phase = {}) {
23
+ const motion = normalizeText(phase.camera_motion ?? phase.cameraMotion).toLowerCase();
24
+ if (motion === 'narrated_pan') return 'linear_scroll_during';
25
+ if (motion === 'return_anchor') return 'scroll_back';
26
+ if (motion === 'cursor_focus') return 'cursor_focus';
27
+ if (motion === 'focus_hold') return 'scroll_to_dwell';
28
+ return '';
29
+ }
30
+
31
+ function normalizeSectionAsPhase(section = {}, index = 0) {
32
+ const phaseId = normalizeText(section.id ?? section.phase_id) || `phase_${index + 1}`;
33
+ const visualAction = section.visual_action && typeof section.visual_action === 'object'
34
+ ? section.visual_action
35
+ : {};
36
+ const focusRegion = normalizeRange(
37
+ section.focus_region
38
+ ?? section.focusRegion
39
+ ?? visualAction.focus_region
40
+ ?? visualAction.focusRegion
41
+ );
42
+ const explicitAction = normalizeText(section.action ?? visualAction.type).toLowerCase();
43
+ const action = explicitAction || inferActionFromCameraMotion(section) || 'scroll_to_dwell';
44
+
45
+ return {
46
+ ...section,
47
+ id: phaseId,
48
+ phase_id: phaseId,
49
+ action,
50
+ focus_region: focusRegion ?? null,
51
+ visual_action: visualAction,
52
+ target_y: section.target_y ?? visualAction.target_y ?? visualAction.to_y ?? null,
53
+ from_y: section.from_y ?? visualAction.from_y ?? null,
54
+ to_y: section.to_y ?? visualAction.to_y ?? null,
55
+ transition_ms: section.transition_ms ?? visualAction.transition_ms ?? null,
56
+ duration_ms: section.duration_ms ?? section.dwell_ms ?? null,
57
+ };
58
+ }
59
+
60
+ export function normalizePlanPhases(plan = {}) {
61
+ const phases = Array.isArray(plan?.phases) ? plan.phases : [];
62
+ if (phases.length > 0) return phases;
63
+
64
+ const sections = Array.isArray(plan?.sections) ? plan.sections : [];
65
+ if (sections.length > 0) {
66
+ return sections.map((section, index) => normalizeSectionAsPhase(section, index));
67
+ }
68
+
69
+ const error = new Error('plan_phases_required');
70
+ error.code = 'PLAN_PHASES_REQUIRED';
71
+ throw error;
72
+ }
73
+
74
+ function resolvePhaseAction(phase = {}) {
75
+ const explicit = normalizeText(phase.action ?? phase.visual_action?.type).toLowerCase();
76
+ if (explicit) return explicit;
77
+ return inferActionFromCameraMotion(phase);
78
+ }
79
+
80
+ function resolvePhaseId(phase = {}, index = 0) {
81
+ return normalizeText(phase.id ?? phase.phase_id) || `phase_${index + 1}`;
82
+ }
83
+
84
+ function nowMs(getNowMs) {
85
+ return Number(getNowMs?.()) || Date.now();
86
+ }
87
+
88
+ function resolveDurationMs(phase, fallback = 0) {
89
+ const parsed = normalizeInteger(phase?.duration_ms, null);
90
+ if (parsed !== null && parsed >= 0) return parsed;
91
+ const dwellMs = normalizeInteger(phase?.dwell_ms, null);
92
+ if (dwellMs !== null && dwellMs >= 0) return dwellMs;
93
+ const secs = Number(phase?.duration_s);
94
+ if (Number.isFinite(secs) && secs >= 0) return Math.round(secs * 1000);
95
+ return fallback;
96
+ }
97
+
98
+ function resolveTransitionMs(phase, fallback) {
99
+ const parsed = normalizeInteger(phase?.transition_ms, null);
100
+ if (parsed !== null && parsed >= 0) return parsed;
101
+ return fallback;
102
+ }
103
+
104
+ function resolveTargetY(phase, fallback = null) {
105
+ const raw = phase?.target_y ?? phase?.to_y ?? phase?.visual_action?.target_y ?? phase?.visual_action?.to_y;
106
+ const parsed = Number(raw);
107
+ if (Number.isFinite(parsed)) return Math.round(parsed);
108
+
109
+ const focusRegion = normalizeRange(
110
+ phase?.focus_region
111
+ ?? phase?.focusRegion
112
+ ?? phase?.highlight?.y_range
113
+ ?? phase?.semantic_focus_region
114
+ ?? phase?.semanticFocusRegion
115
+ ?? phase?.visual_action?.focus_region
116
+ ?? phase?.visual_action?.focusRegion
117
+ );
118
+ if (focusRegion) {
119
+ return Math.round((focusRegion[0] + focusRegion[1]) / 2);
120
+ }
121
+ return fallback;
122
+ }
123
+
124
+ function resolveFromY(phase, fallback = null) {
125
+ const raw = phase?.from_y ?? phase?.visual_action?.from_y;
126
+ const parsed = Number(raw);
127
+ if (!Number.isFinite(parsed)) return fallback;
128
+ return Math.round(parsed);
129
+ }
130
+
131
+ async function animateScroll(page, {
132
+ startY = null,
133
+ targetY,
134
+ durationMs,
135
+ easing = 'easeInOutQuad',
136
+ jitterPx = 0,
137
+ minSteps = 10,
138
+ } = {}) {
139
+ if (!Number.isFinite(Number(targetY))) {
140
+ const error = new Error('phase_target_y_required');
141
+ error.code = 'PHASE_TARGET_Y_REQUIRED';
142
+ throw error;
143
+ }
144
+
145
+ const normalizedDurationMs = Math.max(0, Number(durationMs) || 0);
146
+ const normalizedMinSteps = Math.max(1, Number(minSteps) || 1);
147
+
148
+ await page.evaluate(async ({
149
+ startY: evaluateStartY,
150
+ targetY: evaluateTargetY,
151
+ durationMs: evaluateDurationMs,
152
+ easing: evaluateEasing,
153
+ jitterPx: evaluateJitterPx,
154
+ minSteps: evaluateMinSteps,
155
+ }) => {
156
+ const root = document.scrollingElement || document.documentElement;
157
+ const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
158
+
159
+ const fromY = Number.isFinite(evaluateStartY) ? evaluateStartY : root.scrollTop;
160
+ const toY = evaluateTargetY;
161
+ const delta = toY - fromY;
162
+
163
+ const steps = Math.max(evaluateMinSteps, Math.round(Math.max(1, evaluateDurationMs) / 16));
164
+ const stepDurationMs = evaluateDurationMs <= 0 ? 0 : evaluateDurationMs / steps;
165
+
166
+ const applyEasing = (t) => {
167
+ if (evaluateEasing === 'linear') return t;
168
+ if (evaluateEasing === 'easeOutQuad') return 1 - ((1 - t) * (1 - t));
169
+ return t < 0.5
170
+ ? 2 * t * t
171
+ : 1 - (Math.pow(-2 * t + 2, 2) / 2);
172
+ };
173
+
174
+ root.scrollTo(0, fromY);
175
+ for (let index = 1; index <= steps; index += 1) {
176
+ const t = index / steps;
177
+ const eased = applyEasing(t);
178
+ const jitter = evaluateJitterPx > 0 ? ((Math.random() * 2 - 1) * evaluateJitterPx) : 0;
179
+ root.scrollTo(0, fromY + (delta * eased) + jitter);
180
+ if (stepDurationMs > 0) {
181
+ await wait(stepDurationMs);
182
+ }
183
+ }
184
+ root.scrollTo(0, toY);
185
+ }, {
186
+ startY,
187
+ targetY,
188
+ durationMs: normalizedDurationMs,
189
+ easing,
190
+ jitterPx: Math.max(0, Number(jitterPx) || 0),
191
+ minSteps: normalizedMinSteps,
192
+ });
193
+ }
194
+
195
+ async function executeHold(page, phase) {
196
+ const holdMs = resolveDurationMs(phase, 0);
197
+ if (holdMs > 0) {
198
+ await page.waitForTimeout(holdMs);
199
+ }
200
+ return { anchorY: null };
201
+ }
202
+
203
+ async function executeSmoothScroll(page, phase, { fallbackTargetY = null } = {}) {
204
+ const targetY = resolveTargetY(phase, fallbackTargetY);
205
+ const transitionMs = resolveTransitionMs(phase, 900);
206
+ await animateScroll(page, {
207
+ targetY,
208
+ durationMs: transitionMs,
209
+ easing: 'easeInOutQuad',
210
+ jitterPx: 2,
211
+ minSteps: 18,
212
+ });
213
+ return { anchorY: targetY };
214
+ }
215
+
216
+ async function executeFastScroll(page, phase, { fallbackTargetY = null } = {}) {
217
+ const targetY = resolveTargetY(phase, fallbackTargetY);
218
+ const transitionMs = resolveTransitionMs(phase, 420);
219
+ await animateScroll(page, {
220
+ targetY,
221
+ durationMs: transitionMs,
222
+ easing: 'easeOutQuad',
223
+ jitterPx: 3,
224
+ minSteps: 10,
225
+ });
226
+ return { anchorY: targetY };
227
+ }
228
+
229
+ async function executeLinearScrollDuring(page, phase, {
230
+ fallbackFromY = null,
231
+ fallbackTargetY = null,
232
+ } = {}) {
233
+ const fromY = resolveFromY(phase, fallbackFromY);
234
+ const toY = resolveTargetY(phase, fallbackTargetY);
235
+ const durationMs = resolveDurationMs(phase, null);
236
+ if (!Number.isFinite(Number(durationMs)) || Number(durationMs) <= 0) {
237
+ const error = new Error('linear_scroll_duration_required');
238
+ error.code = 'LINEAR_SCROLL_DURATION_REQUIRED';
239
+ throw error;
240
+ }
241
+
242
+ await animateScroll(page, {
243
+ startY: fromY,
244
+ targetY: toY,
245
+ durationMs,
246
+ easing: 'linear',
247
+ jitterPx: 0,
248
+ minSteps: 12,
249
+ });
250
+
251
+ const dwellMs = normalizeInteger(phase?.dwell_ms, null);
252
+ if (Number.isFinite(dwellMs) && dwellMs > durationMs) {
253
+ await page.waitForTimeout(dwellMs - durationMs);
254
+ }
255
+ return { anchorY: toY };
256
+ }
257
+
258
+ async function executeScrollToDwell(page, phase, { fallbackTargetY = null } = {}) {
259
+ const targetY = resolveTargetY(phase, fallbackTargetY);
260
+ const transitionMs = resolveTransitionMs(phase, 820);
261
+ await animateScroll(page, {
262
+ targetY,
263
+ durationMs: transitionMs,
264
+ easing: 'easeInOutQuad',
265
+ jitterPx: 2,
266
+ minSteps: 16,
267
+ });
268
+ const dwellMs = normalizeInteger(phase?.dwell_ms, null);
269
+ if (Number.isFinite(dwellMs) && dwellMs > 0) {
270
+ await page.waitForTimeout(dwellMs);
271
+ } else {
272
+ const holdMs = resolveDurationMs(phase, 0);
273
+ if (holdMs > 0) {
274
+ await page.waitForTimeout(holdMs);
275
+ }
276
+ }
277
+ return { anchorY: targetY };
278
+ }
279
+
280
+ async function executeScrollBack(page, phase, { fallbackTargetY = 0 } = {}) {
281
+ const targetY = resolveTargetY(phase, fallbackTargetY);
282
+ const transitionMs = resolveTransitionMs(phase, 900);
283
+ await animateScroll(page, {
284
+ targetY,
285
+ durationMs: transitionMs,
286
+ easing: 'easeOutQuad',
287
+ jitterPx: 1,
288
+ minSteps: 14,
289
+ });
290
+ const dwellMs = normalizeInteger(phase?.dwell_ms, null);
291
+ if (Number.isFinite(dwellMs) && dwellMs > 0) {
292
+ await page.waitForTimeout(dwellMs);
293
+ }
294
+ return { anchorY: targetY };
295
+ }
296
+
297
+ async function executeCursorFocus(page, phase, { fallbackTargetY = null } = {}) {
298
+ const targetY = resolveTargetY(phase, fallbackTargetY);
299
+ const transitionMs = resolveTransitionMs(phase, 650);
300
+ await animateScroll(page, {
301
+ targetY,
302
+ durationMs: transitionMs,
303
+ easing: 'easeInOutQuad',
304
+ jitterPx: 1,
305
+ minSteps: 12,
306
+ });
307
+ const dwellMs = normalizeInteger(phase?.dwell_ms, null);
308
+ if (Number.isFinite(dwellMs) && dwellMs > 0) {
309
+ await page.waitForTimeout(dwellMs);
310
+ } else {
311
+ const holdMs = resolveDurationMs(phase, 360);
312
+ if (holdMs > 0) {
313
+ await page.waitForTimeout(holdMs);
314
+ }
315
+ }
316
+ return { anchorY: targetY };
317
+ }
318
+
319
+ async function executePhase(page, phase, {
320
+ lastAnchorY = null,
321
+ initialAnchorY = 0,
322
+ } = {}) {
323
+ const action = resolvePhaseAction(phase);
324
+ const fallbackY = lastAnchorY ?? initialAnchorY;
325
+
326
+ if (action === 'hold') {
327
+ return executeHold(page, phase);
328
+ }
329
+ if (action === 'smooth_scroll') {
330
+ return executeSmoothScroll(page, phase, { fallbackTargetY: fallbackY });
331
+ }
332
+ if (action === 'fast_scroll') {
333
+ return executeFastScroll(page, phase, { fallbackTargetY: fallbackY });
334
+ }
335
+ if (action === 'linear_scroll_during') {
336
+ return executeLinearScrollDuring(page, phase, {
337
+ fallbackFromY: fallbackY,
338
+ fallbackTargetY: fallbackY,
339
+ });
340
+ }
341
+ if (action === 'scroll_to_dwell') {
342
+ return executeScrollToDwell(page, phase, { fallbackTargetY: fallbackY });
343
+ }
344
+ if (action === 'scroll_back') {
345
+ return executeScrollBack(page, phase, { fallbackTargetY: 0 });
346
+ }
347
+ if (action === 'cursor_focus') {
348
+ return executeCursorFocus(page, phase, { fallbackTargetY: fallbackY });
349
+ }
350
+
351
+ const error = new Error(`phase_action_unsupported:${action || 'empty'}`);
352
+ error.code = 'PHASE_ACTION_UNSUPPORTED';
353
+ throw error;
354
+ }
355
+
356
+ function createEvent({ tMs, action, phaseId, phaseAction, detail = {} }) {
357
+ return {
358
+ t_ms: Math.max(0, Math.round(tMs)),
359
+ action,
360
+ phase_id: phaseId,
361
+ phase_action: phaseAction,
362
+ ...detail,
363
+ };
364
+ }
365
+
366
+ export async function executePlanPhases(page, plan, {
367
+ getNowMs = () => Date.now(),
368
+ onEvent = null,
369
+ } = {}) {
370
+ const phases = normalizePlanPhases(plan);
371
+ const startedAt = nowMs(getNowMs);
372
+ const eventsLog = [];
373
+ let lastAnchorY = 0;
374
+
375
+ for (let index = 0; index < phases.length; index += 1) {
376
+ const phase = phases[index];
377
+ const phaseId = resolvePhaseId(phase, index);
378
+ const phaseAction = resolvePhaseAction(phase);
379
+
380
+ const startEvent = createEvent({
381
+ tMs: nowMs(getNowMs) - startedAt,
382
+ action: 'phase_start',
383
+ phaseId,
384
+ phaseAction,
385
+ });
386
+ eventsLog.push(startEvent);
387
+ if (typeof onEvent === 'function') onEvent(startEvent);
388
+
389
+ try {
390
+ const executionResult = await executePhase(page, phase, {
391
+ lastAnchorY,
392
+ initialAnchorY: 0,
393
+ });
394
+ const anchorY = Number(executionResult?.anchorY);
395
+ if (Number.isFinite(anchorY)) {
396
+ lastAnchorY = Math.round(anchorY);
397
+ }
398
+ } catch (error) {
399
+ const errorEvent = createEvent({
400
+ tMs: nowMs(getNowMs) - startedAt,
401
+ action: 'phase_error',
402
+ phaseId,
403
+ phaseAction,
404
+ detail: {
405
+ error: error?.message ?? 'unknown_error',
406
+ },
407
+ });
408
+ eventsLog.push(errorEvent);
409
+ if (typeof onEvent === 'function') onEvent(errorEvent);
410
+ throw error;
411
+ }
412
+
413
+ const endEvent = createEvent({
414
+ tMs: nowMs(getNowMs) - startedAt,
415
+ action: 'phase_end',
416
+ phaseId,
417
+ phaseAction,
418
+ });
419
+ eventsLog.push(endEvent);
420
+ if (typeof onEvent === 'function') onEvent(endEvent);
421
+ }
422
+
423
+ return eventsLog;
424
+ }
@@ -12,11 +12,13 @@ import {
12
12
  probeDurationMs,
13
13
  transcodeForPlatform,
14
14
  } from './_vendor/video/composer/index.js';
15
+ import { recordUrlNarration } from './_vendor/video/recorder/index.js';
15
16
  import {
16
17
  VIDEO_EXT,
17
18
  VIDEO_MIME,
18
19
  writeLocalFileToWorkspace,
19
20
  } from './workspace-file-upload.js';
21
+ import { runRecordUrlNarrationTool } from './record-url-narration-tool.js';
20
22
  import { runSubmitToLibraryTool } from './submit-to-library-tool.js';
21
23
  import { isLeaseInvalidated, clearInvalidatedLease } from './governance-state.js';
22
24
  import { classifyLeaseWindow } from './lease-window.js';
@@ -197,6 +199,7 @@ const DEFAULT_TOOL_CLASSIFICATION = {
197
199
  supersede_goal_field: 'mandatory',
198
200
  request_credential_auth: 'mandatory',
199
201
  generate_voiceover: 'mandatory',
202
+ record_url_narration: 'mandatory',
200
203
  compose_video: 'mandatory',
201
204
  submit_to_library: 'mandatory',
202
205
  register_data_source: 'mandatory',
@@ -1407,6 +1410,30 @@ server.tool('generate_voiceover',
1407
1410
  }
1408
1411
  );
1409
1412
 
1413
+ // ── record_url_narration ────────────────────────────────────────────────────────
1414
+ server.tool('record_url_narration',
1415
+ 'Record a silent video of a URL by orchestrating Xvfb + Chromium + ffmpeg, driven by a video plan. Outputs a silent mp4 plus an events.json timestamp log that compose_video can use to align audio segments.\n\nUse this as the canonical recording step for URL-narration videos. Falls back: if the page needs interactions outside the visual_action vocabulary (clicks, waits, OCR loops), use Monitor (Bash) with custom Playwright instead.\n\nRuntime requirements: this tool only works on a Linux daemon machine with Xvfb + ffmpeg (x11grab) + Chromium installed. macOS / Windows daemons will fail at startup.',
1416
+ {
1417
+ url: z.string().describe('Page URL to record'),
1418
+ plan: z.record(z.any()).describe('Video plan from plan_video / detail_sections — containing phases array with visual_action per phase'),
1419
+ output_path: z.string().optional().describe('Workspace-relative output mp4 path. Default tmp/wx3_video/recorded-{ts}.mp4'),
1420
+ events_path: z.string().optional().describe('Workspace-relative events.json path. Default ${output_path}.events.json'),
1421
+ viewport: z.object({
1422
+ width: z.number().optional(),
1423
+ height: z.number().optional(),
1424
+ }).optional().describe('Default 1080x1920 (mobile portrait). Override only if the plan requires a different shape.'),
1425
+ fps: z.number().optional().describe('Default 30. Do not change unless needed.'),
1426
+ settle_ms: z.number().optional().describe('Default 4000. Settle wait after navigation before recording starts.'),
1427
+ },
1428
+ async (args) => runRecordUrlNarrationTool({
1429
+ args,
1430
+ currentWorkspaceId,
1431
+ workspaceDir: WORKSPACE_DIR,
1432
+ runMandatoryLocalToolFn: runMandatoryLocalTool,
1433
+ recordUrlNarrationFn: recordUrlNarration,
1434
+ })
1435
+ );
1436
+
1410
1437
  // ── compose_video ───────────────────────────────────────────────────────────────
1411
1438
  server.tool('compose_video',
1412
1439
  'Compose a final short video by muxing audio onto a base video, optionally concatenating an outro, and transcoding to platform spec.',
@@ -0,0 +1,165 @@
1
+ import { mkdirSync } from 'fs';
2
+ import path from 'path';
3
+
4
+ function toolText(text) {
5
+ return { content: [{ type: 'text', text }] };
6
+ }
7
+
8
+ function toolError(text) {
9
+ return { isError: true, content: [{ type: 'text', text }] };
10
+ }
11
+
12
+ function normalizeText(value) {
13
+ if (typeof value !== 'string') return '';
14
+ return value.trim();
15
+ }
16
+
17
+ function isPlainObject(value) {
18
+ return !!value && typeof value === 'object' && !Array.isArray(value);
19
+ }
20
+
21
+ function normalizeNumberOrNull(value) {
22
+ const parsed = Number(value);
23
+ if (!Number.isFinite(parsed)) return null;
24
+ return Math.floor(parsed);
25
+ }
26
+
27
+ function deriveDurationMs(recorderOutput) {
28
+ const directDurationMs = normalizeNumberOrNull(recorderOutput?.durationMs ?? recorderOutput?.duration_ms);
29
+ if (directDurationMs != null) return directDurationMs;
30
+
31
+ const eventsLog = Array.isArray(recorderOutput?.events_log) ? recorderOutput.events_log : null;
32
+ if (!eventsLog || eventsLog.length === 0) return null;
33
+
34
+ const lastTms = eventsLog.reduce((max, event) => {
35
+ const next = normalizeNumberOrNull(event?.t_ms);
36
+ if (next == null) return max;
37
+ return Math.max(max, next);
38
+ }, 0);
39
+ return lastTms > 0 ? lastTms : null;
40
+ }
41
+
42
+ function derivePhaseCount({ plan, recorderOutput }) {
43
+ const explicit = normalizeNumberOrNull(recorderOutput?.phases);
44
+ if (explicit != null) return explicit;
45
+
46
+ if (Array.isArray(plan?.phases)) return plan.phases.length;
47
+ if (Array.isArray(plan?.sections)) return plan.sections.length;
48
+ return null;
49
+ }
50
+
51
+ export function validateRecordUrlNarrationArgs(args = {}) {
52
+ const normalizedUrl = normalizeText(args.url);
53
+ if (!normalizedUrl) {
54
+ const error = new Error('url is required for record_url_narration.');
55
+ error.code = 'RECORD_URL_REQUIRED';
56
+ throw error;
57
+ }
58
+
59
+ if (!isPlainObject(args.plan)) {
60
+ const error = new Error('plan is required for record_url_narration.');
61
+ error.code = 'RECORD_PLAN_REQUIRED';
62
+ throw error;
63
+ }
64
+
65
+ return {
66
+ ...(args ?? {}),
67
+ url: normalizedUrl,
68
+ };
69
+ }
70
+
71
+ export function resolveRecordUrlNarrationPaths({
72
+ workspaceDir,
73
+ outputPath,
74
+ eventsPath,
75
+ nowMs = () => Date.now(),
76
+ } = {}) {
77
+ const normalizedOutputPath = normalizeText(outputPath) || `tmp/wx3_video/recorded-${nowMs()}.mp4`;
78
+ const resolvedOutputPath = path.resolve(workspaceDir, normalizedOutputPath);
79
+
80
+ const normalizedEventsPath = normalizeText(eventsPath);
81
+ const resolvedEventsPath = normalizedEventsPath
82
+ ? path.resolve(workspaceDir, normalizedEventsPath)
83
+ : `${resolvedOutputPath}.events.json`;
84
+
85
+ return {
86
+ resolvedOutputPath,
87
+ resolvedEventsPath,
88
+ };
89
+ }
90
+
91
+ export async function runRecordUrlNarrationTool({
92
+ args = {},
93
+ currentWorkspaceId = '',
94
+ workspaceDir = process.cwd(),
95
+ runMandatoryLocalToolFn,
96
+ recordUrlNarrationFn,
97
+ nowMs = () => Date.now(),
98
+ } = {}) {
99
+ if (!currentWorkspaceId) {
100
+ return toolError('No workspace context.');
101
+ }
102
+ if (typeof runMandatoryLocalToolFn !== 'function') {
103
+ return toolError('Error: record_url_narration runMandatoryLocalToolFn is required.');
104
+ }
105
+ if (typeof recordUrlNarrationFn !== 'function') {
106
+ return toolError('Error: record_url_narration executor is required.');
107
+ }
108
+
109
+ let validatedInput;
110
+ try {
111
+ validatedInput = validateRecordUrlNarrationArgs(args);
112
+ } catch (error) {
113
+ return toolError(`Error: ${error.message}`);
114
+ }
115
+
116
+ try {
117
+ const result = await runMandatoryLocalToolFn({
118
+ toolName: 'record_url_narration',
119
+ toolInput: validatedInput,
120
+ executor: async (checkedInput = {}) => {
121
+ const mergedInput = {
122
+ ...validatedInput,
123
+ ...checkedInput,
124
+ };
125
+ const finalInput = validateRecordUrlNarrationArgs(mergedInput);
126
+ const { resolvedOutputPath, resolvedEventsPath } = resolveRecordUrlNarrationPaths({
127
+ workspaceDir,
128
+ outputPath: finalInput.output_path,
129
+ eventsPath: finalInput.events_path,
130
+ nowMs,
131
+ });
132
+
133
+ mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
134
+ mkdirSync(path.dirname(resolvedEventsPath), { recursive: true });
135
+
136
+ const recorderOutput = await recordUrlNarrationFn({
137
+ url: finalInput.url,
138
+ plan: finalInput.plan,
139
+ output_path: resolvedOutputPath,
140
+ events_path: resolvedEventsPath,
141
+ viewport: finalInput.viewport,
142
+ fps: finalInput.fps,
143
+ settle_ms: finalInput.settle_ms,
144
+ });
145
+
146
+ return {
147
+ videoPath: resolvedOutputPath,
148
+ eventsPath: resolvedEventsPath,
149
+ durationMs: deriveDurationMs(recorderOutput),
150
+ phases: derivePhaseCount({ plan: finalInput.plan, recorderOutput }),
151
+ };
152
+ },
153
+ });
154
+
155
+ return toolText(
156
+ `Recorded URL narration.\n`
157
+ + `video_path=${result.videoPath}\n`
158
+ + `events_path=${result.eventsPath}\n`
159
+ + `duration_ms=${result.durationMs ?? 'unknown'}\n`
160
+ + `phases=${result.phases ?? 'n/a'}`
161
+ );
162
+ } catch (error) {
163
+ return toolError(`Error: ${error.message}`);
164
+ }
165
+ }