@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,362 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { stat, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ import { launchChromiumMobile, openPageAndSettle } from './chromium-driver.js';
7
+ import { defaultDisplayPool } from './display-pool.js';
8
+ import {
9
+ createUnexpectedExitWatcher,
10
+ startFfmpegCapture,
11
+ stopFfmpegCapture,
12
+ waitForProcessExit,
13
+ } from './ffmpeg-runner.js';
14
+ import { executePlanPhases, normalizePlanPhases } from './plan-executor.js';
15
+
16
+ const DEFAULT_VIEWPORT = Object.freeze({ width: 1080, height: 1920 });
17
+ const DEFAULT_FPS = 30;
18
+
19
+ function normalizeText(value) {
20
+ if (typeof value !== 'string') return '';
21
+ return value.trim();
22
+ }
23
+
24
+ function normalizeInteger(value, fallback) {
25
+ const parsed = Number.parseInt(String(value ?? ''), 10);
26
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
27
+ return parsed;
28
+ }
29
+
30
+ function normalizeViewport(viewport = {}) {
31
+ return {
32
+ width: normalizeInteger(viewport?.width, DEFAULT_VIEWPORT.width),
33
+ height: normalizeInteger(viewport?.height, DEFAULT_VIEWPORT.height),
34
+ };
35
+ }
36
+
37
+ function resolveOutputPath(rawValue) {
38
+ const normalized = normalizeText(rawValue);
39
+ if (!normalized) {
40
+ const error = new Error('output_path_required');
41
+ error.code = 'OUTPUT_PATH_REQUIRED';
42
+ throw error;
43
+ }
44
+ return path.resolve(normalized);
45
+ }
46
+
47
+ function resolveEventsPath(outputPath, eventsPath) {
48
+ const explicit = normalizeText(eventsPath);
49
+ if (explicit) return path.resolve(explicit);
50
+
51
+ const parsed = path.parse(outputPath);
52
+ return path.join(parsed.dir, `${parsed.name}.events.json`);
53
+ }
54
+
55
+ function resolveUrl({ url, plan }) {
56
+ const fromOptions = normalizeText(url);
57
+ if (fromOptions) return fromOptions;
58
+
59
+ const fromPlan = normalizeText(plan?.url ?? plan?.target_url ?? plan?.page_url);
60
+ if (fromPlan) return fromPlan;
61
+
62
+ const error = new Error('record_url_required');
63
+ error.code = 'RECORD_URL_REQUIRED';
64
+ throw error;
65
+ }
66
+
67
+ function estimatePlanDurationMs(plan = {}) {
68
+ let phases = [];
69
+ try {
70
+ phases = normalizePlanPhases(plan);
71
+ } catch {
72
+ phases = [];
73
+ }
74
+
75
+ return phases.reduce((total, phase) => {
76
+ const action = String(phase?.action ?? phase?.visual_action?.type ?? '').trim().toLowerCase();
77
+ const durationMs = Number(phase?.duration_ms);
78
+ const dwellMs = Number(phase?.dwell_ms);
79
+ const transitionMs = Number(phase?.transition_ms ?? phase?.visual_action?.transition_ms);
80
+ const effectiveHoldMs = Number.isFinite(dwellMs) && dwellMs > 0
81
+ ? dwellMs
82
+ : durationMs;
83
+
84
+ if (action === 'hold' && Number.isFinite(effectiveHoldMs) && effectiveHoldMs > 0) {
85
+ return total + effectiveHoldMs;
86
+ }
87
+ if (action === 'linear_scroll_during') {
88
+ if (Number.isFinite(effectiveHoldMs) && effectiveHoldMs > 0) return total + effectiveHoldMs;
89
+ return total + 1200;
90
+ }
91
+ if (action === 'scroll_to_dwell' || action === 'cursor_focus' || action === 'scroll_back') {
92
+ let next = total;
93
+ if (Number.isFinite(transitionMs) && transitionMs > 0) next += transitionMs;
94
+ if (Number.isFinite(effectiveHoldMs) && effectiveHoldMs > 0) next += effectiveHoldMs;
95
+ if (next === total) next += 1200;
96
+ return next;
97
+ }
98
+ if (Number.isFinite(transitionMs) && transitionMs > 0) {
99
+ return total + transitionMs;
100
+ }
101
+ if (Number.isFinite(durationMs) && durationMs > 0) {
102
+ return total + durationMs;
103
+ }
104
+ return total + 800;
105
+ }, 0);
106
+ }
107
+
108
+ function createXvfbExitError({ code, signal, stderr }) {
109
+ const error = new Error(`xvfb_exited_unexpectedly:code=${code ?? 'null'}:signal=${signal ?? 'none'}`);
110
+ error.code = 'XVFB_EXITED_UNEXPECTEDLY';
111
+ error.exitCode = code;
112
+ error.signal = signal;
113
+ error.stderr = stderr;
114
+ return error;
115
+ }
116
+
117
+ async function stopXvfb(runner, {
118
+ signal = 'SIGTERM',
119
+ timeoutMs = 5000,
120
+ killTimeoutMs = 2000,
121
+ } = {}) {
122
+ const child = runner?.child;
123
+ if (!child || child.exitCode !== null) return child?.exitCode ?? 0;
124
+
125
+ child.kill(signal);
126
+ const firstExit = await waitForProcessExit(child, timeoutMs);
127
+ if (!firstExit.timedOut) return firstExit.code;
128
+
129
+ child.kill('SIGKILL');
130
+ const forceExit = await waitForProcessExit(child, killTimeoutMs);
131
+ return forceExit.code;
132
+ }
133
+
134
+ async function startXvfb({
135
+ display,
136
+ width,
137
+ height,
138
+ colorDepth = 24,
139
+ startupProbeMs = 1200,
140
+ xvfbBin = 'Xvfb',
141
+ } = {}) {
142
+ const args = [
143
+ display,
144
+ '-screen',
145
+ '0',
146
+ `${width}x${height}x${colorDepth}`,
147
+ '-ac',
148
+ '+extension',
149
+ 'RANDR',
150
+ ];
151
+
152
+ let stderr = '';
153
+ let spawnError = null;
154
+ const child = spawn(xvfbBin, args, {
155
+ stdio: ['ignore', 'pipe', 'pipe'],
156
+ });
157
+
158
+ child.stderr?.on('data', (chunk) => {
159
+ const next = `${stderr}${String(chunk)}`;
160
+ stderr = next.length > 8000 ? next.slice(next.length - 8000) : next;
161
+ });
162
+ child.once('error', (error) => {
163
+ spawnError = error;
164
+ });
165
+
166
+ await new Promise(resolve => setTimeout(resolve, Math.max(0, Number(startupProbeMs) || 0)));
167
+
168
+ if (spawnError) {
169
+ const error = new Error(`xvfb_spawn_failed:${spawnError.message}`);
170
+ error.code = 'XVFB_SPAWN_FAILED';
171
+ throw error;
172
+ }
173
+
174
+ if (child.exitCode !== null) {
175
+ throw createXvfbExitError({
176
+ code: child.exitCode,
177
+ signal: child.signalCode,
178
+ stderr,
179
+ });
180
+ }
181
+
182
+ const runner = {
183
+ child,
184
+ display,
185
+ args,
186
+ getStderr: () => stderr,
187
+ stop: (options) => stopXvfb(runner, options),
188
+ };
189
+
190
+ return runner;
191
+ }
192
+
193
+ async function scrollToTop(page) {
194
+ await page.evaluate(() => {
195
+ const root = document.scrollingElement || document.documentElement;
196
+ root.scrollTo(0, 0);
197
+ });
198
+ }
199
+
200
+ export async function recordUrlNarration({
201
+ plan,
202
+ output_path,
203
+ outputPath = output_path,
204
+ events_path,
205
+ eventsPath = events_path,
206
+ url,
207
+ viewport = DEFAULT_VIEWPORT,
208
+ fps = DEFAULT_FPS,
209
+ settle_ms = 4000,
210
+ displayPool = defaultDisplayPool,
211
+ ffmpegDurationBufferSec = 8,
212
+ startupProbeMs = 1200,
213
+ ffmpegStopTimeoutMs = 10000,
214
+ xvfbStopTimeoutMs = 5000,
215
+ postPlanTailMs = 600,
216
+ } = {}) {
217
+ const phases = normalizePlanPhases(plan);
218
+ const executablePlan = {
219
+ ...(plan && typeof plan === 'object' ? plan : {}),
220
+ phases,
221
+ };
222
+
223
+ const resolvedOutputPath = resolveOutputPath(outputPath);
224
+ const resolvedEventsPath = resolveEventsPath(resolvedOutputPath, eventsPath);
225
+ const resolvedUrl = resolveUrl({ url, plan });
226
+ const normalizedViewport = normalizeViewport(viewport);
227
+ const normalizedFps = normalizeInteger(fps, DEFAULT_FPS);
228
+
229
+ mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
230
+ mkdirSync(path.dirname(resolvedEventsPath), { recursive: true });
231
+
232
+ let displayLease;
233
+ let xvfb;
234
+ let ffmpeg;
235
+ let browserSession;
236
+ let xvfbWatcher;
237
+ let ffmpegWatcher;
238
+ let primaryError = null;
239
+
240
+ const cleanupErrors = [];
241
+
242
+ try {
243
+ displayLease = await displayPool.acquireDisplay();
244
+ const display = displayLease.display;
245
+
246
+ xvfb = await startXvfb({
247
+ display,
248
+ width: normalizedViewport.width,
249
+ height: normalizedViewport.height,
250
+ startupProbeMs,
251
+ });
252
+ xvfbWatcher = createUnexpectedExitWatcher(xvfb.child, 'xvfb');
253
+
254
+ browserSession = await launchChromiumMobile({
255
+ display,
256
+ viewport: normalizedViewport,
257
+ });
258
+ await openPageAndSettle(browserSession.page, {
259
+ url: resolvedUrl,
260
+ settleMs: settle_ms,
261
+ });
262
+
263
+ const estimatedDurationMs = estimatePlanDurationMs(executablePlan);
264
+ const estimatedDurationSec = Math.max(
265
+ 5,
266
+ Math.ceil(estimatedDurationMs / 1000) + Math.max(0, Number(ffmpegDurationBufferSec) || 0)
267
+ );
268
+
269
+ ffmpeg = await startFfmpegCapture({
270
+ display,
271
+ outputPath: resolvedOutputPath,
272
+ width: normalizedViewport.width,
273
+ height: normalizedViewport.height,
274
+ fps: normalizedFps,
275
+ durationSec: estimatedDurationSec,
276
+ startupProbeMs,
277
+ });
278
+ ffmpegWatcher = createUnexpectedExitWatcher(ffmpeg.child, 'ffmpeg');
279
+
280
+ await scrollToTop(browserSession.page);
281
+ await browserSession.page.waitForTimeout(350);
282
+
283
+ const eventsLog = await Promise.race([
284
+ executePlanPhases(browserSession.page, executablePlan),
285
+ xvfbWatcher.promise,
286
+ ffmpegWatcher.promise,
287
+ ]);
288
+
289
+ await browserSession.page.waitForTimeout(Math.max(0, Number(postPlanTailMs) || 0));
290
+
291
+ ffmpegWatcher.deactivate();
292
+ await stopFfmpegCapture(ffmpeg, {
293
+ timeoutMs: ffmpegStopTimeoutMs,
294
+ });
295
+
296
+ xvfbWatcher.deactivate();
297
+
298
+ await writeFile(resolvedEventsPath, JSON.stringify(eventsLog, null, 2), 'utf8');
299
+
300
+ const videoStat = await stat(resolvedOutputPath);
301
+ if (!videoStat.isFile() || videoStat.size <= 0) {
302
+ const error = new Error('recorded_video_empty');
303
+ error.code = 'RECORDED_VIDEO_EMPTY';
304
+ throw error;
305
+ }
306
+
307
+ return {
308
+ video_path: resolvedOutputPath,
309
+ events_path: resolvedEventsPath,
310
+ events_log: eventsLog,
311
+ display,
312
+ };
313
+ } catch (error) {
314
+ primaryError = error;
315
+ throw error;
316
+ } finally {
317
+ ffmpegWatcher?.deactivate();
318
+ xvfbWatcher?.deactivate();
319
+
320
+ if (browserSession) {
321
+ try {
322
+ await browserSession.close();
323
+ } catch (closeError) {
324
+ cleanupErrors.push(`browser_close_failed:${closeError.message}`);
325
+ }
326
+ }
327
+
328
+ if (ffmpeg) {
329
+ try {
330
+ await stopFfmpegCapture(ffmpeg, {
331
+ timeoutMs: ffmpegStopTimeoutMs,
332
+ });
333
+ } catch (stopError) {
334
+ cleanupErrors.push(`ffmpeg_stop_failed:${stopError.message}`);
335
+ }
336
+ }
337
+
338
+ if (xvfb) {
339
+ try {
340
+ await stopXvfb(xvfb, {
341
+ timeoutMs: xvfbStopTimeoutMs,
342
+ });
343
+ } catch (stopError) {
344
+ cleanupErrors.push(`xvfb_stop_failed:${stopError.message}`);
345
+ }
346
+ }
347
+
348
+ if (displayLease) {
349
+ displayLease.release();
350
+ }
351
+
352
+ if (cleanupErrors.length > 0) {
353
+ if (primaryError) {
354
+ primaryError.cleanupErrors = cleanupErrors;
355
+ } else {
356
+ const error = new Error(`record_cleanup_errors:${cleanupErrors.join('|')}`);
357
+ error.code = 'RECORD_CLEANUP_ERRORS';
358
+ throw error;
359
+ }
360
+ }
361
+ }
362
+ }