@lightcone-ai/daemon 0.14.14 → 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,14 @@ 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';
22
+ import { runSubmitToLibraryTool } from './submit-to-library-tool.js';
20
23
  import { isLeaseInvalidated, clearInvalidatedLease } from './governance-state.js';
21
24
  import { classifyLeaseWindow } from './lease-window.js';
22
25
 
@@ -196,7 +199,9 @@ const DEFAULT_TOOL_CLASSIFICATION = {
196
199
  supersede_goal_field: 'mandatory',
197
200
  request_credential_auth: 'mandatory',
198
201
  generate_voiceover: 'mandatory',
202
+ record_url_narration: 'mandatory',
199
203
  compose_video: 'mandatory',
204
+ submit_to_library: 'mandatory',
200
205
  register_data_source: 'mandatory',
201
206
  bind_workspace_scenario: 'mandatory',
202
207
  create_workspace: 'mandatory',
@@ -254,6 +259,7 @@ const CACHE_INVALIDATION_TOOLS = new Set([
254
259
  'supersede_goal_field',
255
260
  'request_credential_auth',
256
261
  'register_data_source',
262
+ 'submit_to_library',
257
263
  'bind_workspace_scenario',
258
264
  'create_workspace',
259
265
  'rename_workspace',
@@ -331,6 +337,7 @@ function inferToolForApi(method, apiPath, body) {
331
337
  if (method === 'POST' && cleanPath === '/goal-fields/supersede') return 'supersede_goal_field';
332
338
  if (method === 'POST' && cleanPath === '/credential-auth/request') return 'request_credential_auth';
333
339
  if (method === 'POST' && cleanPath === '/tts/voiceover') return 'generate_voiceover';
340
+ if (method === 'POST' && cleanPath === '/content-library/submit') return 'submit_to_library';
334
341
  if (method === 'POST' && cleanPath === '/api/data-sources') return 'register_data_source';
335
342
  if (method === 'POST' && cleanPath === '/orchestrate/decision') return 'write_governance_decision';
336
343
  if (method === 'POST' && cleanPath === '/orchestrate/correction') return 'write_governance_correction';
@@ -1403,6 +1410,30 @@ server.tool('generate_voiceover',
1403
1410
  }
1404
1411
  );
1405
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
+
1406
1437
  // ── compose_video ───────────────────────────────────────────────────────────────
1407
1438
  server.tool('compose_video',
1408
1439
  'Compose a final short video by muxing audio onto a base video, optionally concatenating an outro, and transcoding to platform spec.',
@@ -1503,6 +1534,26 @@ server.tool('compose_video',
1503
1534
  }
1504
1535
  );
1505
1536
 
1537
+ // ── submit_to_library ──────────────────────────────────────────────────────────
1538
+ server.tool('submit_to_library',
1539
+ '把已生成的视频成片归档进内容库(content_video_draft entry)。调用前 mp4 必须已经通过 write_workspace_file 落到 workspace 的 artifacts/ 路径。归档后内容库会出现一张新卡片,含视频预览 + 元数据 + 后续支持发布/回采链路。',
1540
+ {
1541
+ video_path: z.string().describe('Workspace-relative video path, e.g. "artifacts/foo.mp4"'),
1542
+ title: z.string().optional().describe('Library card 标题;若省略由系统从 understanding 推导'),
1543
+ summary: z.string().optional().describe('一句话摘要'),
1544
+ source_url: z.string().optional().describe('原始 URL(如有)'),
1545
+ target_platform: z.string().optional().describe('目标发布平台,如 xhs / douyin'),
1546
+ metadata: z.record(z.any()).optional().describe('其它 metadata(brand_voice / persona / account / goal_state 等)'),
1547
+ understanding: z.record(z.any()).optional().describe('analyze_page 输出'),
1548
+ plan: z.record(z.any()).optional().describe('plan_video / detail_sections 输出'),
1549
+ },
1550
+ async (args) => runSubmitToLibraryTool({
1551
+ args,
1552
+ currentWorkspaceId,
1553
+ apiFn: api,
1554
+ })
1555
+ );
1556
+
1506
1557
  // ── register_data_source ───────────────────────────────────────────────────────
1507
1558
  server.tool('register_data_source',
1508
1559
  'Register a workspace data source without binding credential yet. Returns a one-time secure auth URL (10-minute expiry) that should be sent to the user via IM.',
@@ -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
+ }