@browser_use/pi 0.1.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.
Files changed (85) hide show
  1. package/.env.example +6 -0
  2. package/LICENSE +21 -0
  3. package/README.md +68 -0
  4. package/dist/agent.d.ts +15 -0
  5. package/dist/agent.js +381 -0
  6. package/dist/agent.js.map +1 -0
  7. package/dist/browser.d.ts +52 -0
  8. package/dist/browser.js +264 -0
  9. package/dist/browser.js.map +1 -0
  10. package/dist/cdp.d.ts +39 -0
  11. package/dist/cdp.js +291 -0
  12. package/dist/cdp.js.map +1 -0
  13. package/dist/context.d.ts +24 -0
  14. package/dist/context.js +143 -0
  15. package/dist/context.js.map +1 -0
  16. package/dist/control.d.ts +18 -0
  17. package/dist/control.js +84 -0
  18. package/dist/control.js.map +1 -0
  19. package/dist/events.d.ts +40 -0
  20. package/dist/events.js +79 -0
  21. package/dist/events.js.map +1 -0
  22. package/dist/highlight.d.ts +3 -0
  23. package/dist/highlight.js +102 -0
  24. package/dist/highlight.js.map +1 -0
  25. package/dist/history.d.ts +22 -0
  26. package/dist/history.js +126 -0
  27. package/dist/history.js.map +1 -0
  28. package/dist/images.d.ts +14 -0
  29. package/dist/images.js +104 -0
  30. package/dist/images.js.map +1 -0
  31. package/dist/index.d.ts +77 -0
  32. package/dist/index.js +422 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/model-stream.d.ts +3 -0
  35. package/dist/model-stream.js +78 -0
  36. package/dist/model-stream.js.map +1 -0
  37. package/dist/observer.d.ts +16 -0
  38. package/dist/observer.js +56 -0
  39. package/dist/observer.js.map +1 -0
  40. package/dist/page.d.ts +58 -0
  41. package/dist/page.js +189 -0
  42. package/dist/page.js.map +1 -0
  43. package/dist/policy.d.ts +18 -0
  44. package/dist/policy.js +195 -0
  45. package/dist/policy.js.map +1 -0
  46. package/dist/prompt.d.ts +1 -0
  47. package/dist/prompt.js +41 -0
  48. package/dist/prompt.js.map +1 -0
  49. package/dist/protocol.d.ts +70 -0
  50. package/dist/protocol.js +7 -0
  51. package/dist/protocol.js.map +1 -0
  52. package/dist/recording.d.ts +44 -0
  53. package/dist/recording.js +120 -0
  54. package/dist/recording.js.map +1 -0
  55. package/dist/research-tools.d.ts +3 -0
  56. package/dist/research-tools.js +67 -0
  57. package/dist/research-tools.js.map +1 -0
  58. package/dist/runtime.d.ts +39 -0
  59. package/dist/runtime.js +268 -0
  60. package/dist/runtime.js.map +1 -0
  61. package/dist/server.d.ts +2 -0
  62. package/dist/server.js +251 -0
  63. package/dist/server.js.map +1 -0
  64. package/dist/telemetry.d.ts +3 -0
  65. package/dist/telemetry.js +41 -0
  66. package/dist/telemetry.js.map +1 -0
  67. package/dist/types.d.ts +93 -0
  68. package/dist/types.js +2 -0
  69. package/dist/types.js.map +1 -0
  70. package/dist/video.d.ts +18 -0
  71. package/dist/video.js +177 -0
  72. package/dist/video.js.map +1 -0
  73. package/dist/worker.d.ts +1 -0
  74. package/dist/worker.js +329 -0
  75. package/dist/worker.js.map +1 -0
  76. package/examples/README.md +58 -0
  77. package/examples/apply-to-job.ts +57 -0
  78. package/examples/ehr.ts +53 -0
  79. package/examples/extract.ts +50 -0
  80. package/examples/form.ts +36 -0
  81. package/examples/onepassword.ts +66 -0
  82. package/examples/qa.ts +72 -0
  83. package/examples/research.ts +35 -0
  84. package/examples/stripe-link.ts +166 -0
  85. package/package.json +66 -0
@@ -0,0 +1,41 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ // Public, write-only PostHog project token. EU ingestion; no SDK or persistent identifier.
3
+ const token = 'phc_tcNY9BmpauyrDHDVhrhkFCaQEpH3vMnx3TDUZ49TWCek';
4
+ const processId = randomUUID();
5
+ export function telemetry(enabled, browser) {
6
+ const disabled = enabled === false ||
7
+ process.env.DO_NOT_TRACK === '1' ||
8
+ ['false', '0'].includes(process.env.ANONYMIZED_TELEMETRY?.toLowerCase() ?? '') ||
9
+ !!process.env.NODE_TEST_CONTEXT;
10
+ const kind = browser?.kind ?? (browser && 'cdpUrl' in browser && browser.cdpUrl ? 'cdp' : 'chromium');
11
+ return (result) => {
12
+ if (disabled)
13
+ return;
14
+ // Explicit allowlist. Never spread config/results: they contain tasks, paths and credentials.
15
+ const properties = {
16
+ distinct_id: processId,
17
+ $process_person_profile: false,
18
+ $geoip_disable: true,
19
+ $ip: null,
20
+ sdk_version: '0.1.0',
21
+ os: process.platform,
22
+ node_major: process.versions.node.split('.')[0],
23
+ browser: kind,
24
+ status: result.status,
25
+ steps: result.steps,
26
+ duration_ms: result.durationMs,
27
+ input_tokens: result.usage.input,
28
+ output_tokens: result.usage.output,
29
+ estimated_cost_usd: result.usage.cost.total,
30
+ };
31
+ // Bounded and best-effort. Telemetry cannot change the run result or trigger retries.
32
+ void fetch('https://eu.i.posthog.com/i/v0/e/', {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ api_key: token, event: 'bu_pi_run', properties }),
36
+ signal: AbortSignal.timeout(1000),
37
+ redirect: 'error',
38
+ }).catch(() => { });
39
+ };
40
+ }
41
+ //# sourceMappingURL=telemetry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telemetry.js","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAIzC,2FAA2F;AAC3F,MAAM,KAAK,GAAG,kDAAkD,CAAC;AACjE,MAAM,SAAS,GAAG,UAAU,EAAE,CAAC;AAC/B,MAAM,UAAU,SAAS,CAAC,OAA4B,EAAE,OAAmC;IACzF,MAAM,QAAQ,GACZ,OAAO,KAAK,KAAK;QACjB,OAAO,CAAC,GAAG,CAAC,YAAY,KAAK,GAAG;QAChC,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,oBAAoB,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;QAC9E,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC;IAClC,MAAM,IAAI,GACR,OAAO,EAAE,IAAI,IAAI,CAAC,OAAO,IAAI,QAAQ,IAAI,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC;IAC3F,OAAO,CAAC,MAA0B,EAAQ,EAAE;QAC1C,IAAI,QAAQ;YAAE,OAAO;QACrB,8FAA8F;QAC9F,MAAM,UAAU,GAAG;YACjB,WAAW,EAAE,SAAS;YACtB,uBAAuB,EAAE,KAAK;YAC9B,cAAc,EAAE,IAAI;YACpB,GAAG,EAAE,IAAI;YACT,WAAW,EAAE,OAAO;YACpB,EAAE,EAAE,OAAO,CAAC,QAAQ;YACpB,UAAU,EAAE,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YAC/C,OAAO,EAAE,IAAI;YACb,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,KAAK,EAAE,MAAM,CAAC,KAAK;YACnB,WAAW,EAAE,MAAM,CAAC,UAAU;YAC9B,YAAY,EAAE,MAAM,CAAC,KAAK,CAAC,KAAK;YAChC,aAAa,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM;YAClC,kBAAkB,EAAE,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK;SAC5C,CAAC;QACF,sFAAsF;QACtF,KAAK,KAAK,CAAC,kCAAkC,EAAE;YAC7C,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,CAAC;YACxE,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC;YACjC,QAAQ,EAAE,OAAO;SAClB,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACrB,CAAC,CAAC;AACJ,CAAC","sourcesContent":["import { randomUUID } from 'node:crypto';\nimport type { BrowserOptions } from './browser.js';\nimport type { RunResult } from './types.js';\n\n// Public, write-only PostHog project token. EU ingestion; no SDK or persistent identifier.\nconst token = 'phc_tcNY9BmpauyrDHDVhrhkFCaQEpH3vMnx3TDUZ49TWCek';\nconst processId = randomUUID();\nexport function telemetry(enabled: boolean | undefined, browser: BrowserOptions | undefined) {\n const disabled =\n enabled === false ||\n process.env.DO_NOT_TRACK === '1' ||\n ['false', '0'].includes(process.env.ANONYMIZED_TELEMETRY?.toLowerCase() ?? '') ||\n !!process.env.NODE_TEST_CONTEXT;\n const kind =\n browser?.kind ?? (browser && 'cdpUrl' in browser && browser.cdpUrl ? 'cdp' : 'chromium');\n return (result: RunResult<unknown>): void => {\n if (disabled) return;\n // Explicit allowlist. Never spread config/results: they contain tasks, paths and credentials.\n const properties = {\n distinct_id: processId,\n $process_person_profile: false,\n $geoip_disable: true,\n $ip: null,\n sdk_version: '0.1.0',\n os: process.platform,\n node_major: process.versions.node.split('.')[0],\n browser: kind,\n status: result.status,\n steps: result.steps,\n duration_ms: result.durationMs,\n input_tokens: result.usage.input,\n output_tokens: result.usage.output,\n estimated_cost_usd: result.usage.cost.total,\n };\n // Bounded and best-effort. Telemetry cannot change the run result or trigger retries.\n void fetch('https://eu.i.posthog.com/i/v0/e/', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ api_key: token, event: 'bu_pi_run', properties }),\n signal: AbortSignal.timeout(1000),\n redirect: 'error',\n }).catch(() => {});\n };\n}\n"]}
@@ -0,0 +1,93 @@
1
+ import type { AgentEvent, AgentState, StreamFn, ThinkingLevel, BeforeToolCallContext, BeforeToolCallResult, AfterToolCallContext, AfterToolCallResult } from '@earendil-works/pi-agent-core';
2
+ import type { Models, Usage } from '@earendil-works/pi-ai';
3
+ import type { RecordingOptions } from './recording.js';
4
+ import type { DomainOptions, SensitiveData } from './policy.js';
5
+ import type { BrowserOptions } from './browser.js';
6
+ export interface BrowserUseOptions extends DomainOptions {
7
+ sensitiveData?: SensitiveData;
8
+ /** Anonymous run counters. Disable with false, DO_NOT_TRACK=1 or ANONYMIZED_TELEMETRY=false. */
9
+ telemetry?: boolean;
10
+ /** provider/model ID, e.g. openai/gpt-5.4. No model is silently substituted. */
11
+ model: string;
12
+ browser?: BrowserOptions;
13
+ /** Persistent output directory. Defaults to a new OS temporary directory. */
14
+ workspace?: string;
15
+ /** Supply a Pi collection for custom providers or freshly released model definitions. */
16
+ models?: Models;
17
+ reasoning?: ThinkingLevel;
18
+ /** Opt-in Pi read/write/edit/bash tools. Not a filesystem sandbox. */
19
+ researchTools?: boolean;
20
+ tools?: AgentState['tools'];
21
+ instructions?: string;
22
+ operationTimeoutMs?: number;
23
+ cellTimeoutMs?: number;
24
+ /** Whole model response, including connection setup. Default 300000 ms. */
25
+ modelTimeoutMs?: number;
26
+ /** Whole compaction response. Default 120000 ms; original context survives failure. */
27
+ compactionTimeoutMs?: number;
28
+ maxOutputChars?: number;
29
+ /** Before each tool call. Return {block:true, reason} to deny it. */
30
+ beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
31
+ /** Advanced transport override; useful for deterministic tests or a model gateway. */
32
+ streamFn?: StreamFn;
33
+ /** Named values to redact from saved text/events. Does not redact screenshots. */
34
+ redact?: string[];
35
+ log?: false | 'pretty' | 'json';
36
+ hookTimeoutMs?: number;
37
+ afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;
38
+ /** Return feedback to reject a schema-valid result and let the agent correct it. */
39
+ validateResult?: (output: unknown, signal?: AbortSignal) => Promise<string | void>;
40
+ /** Restore a versioned transcript. Browser profile and JS heap are separate. */
41
+ historyFile?: string;
42
+ recording?: boolean | RecordingOptions;
43
+ /** Show temporary orange corner brackets on the element just clicked or typed into. Default false. */
44
+ highlightActions?: boolean;
45
+ }
46
+ export interface RunOptions {
47
+ maxSteps?: number;
48
+ timeoutMs?: number;
49
+ /** Soft threshold: checked between model turns. May exceed by one response. */
50
+ maxCostUsd?: number;
51
+ /** Automatically summarize older context with Pi; false retains hard-stop behavior. */
52
+ compaction?: boolean;
53
+ maxContextChars?: number;
54
+ signal?: AbortSignal;
55
+ /** Best-effort nonblocking observations. Coalesces under load; honor the abort signal. */
56
+ observe?: (event: AgentEvent, signal: AbortSignal) => void | Promise<void>;
57
+ observerTimeoutMs?: number;
58
+ /** Pi lifecycle events. Async listeners apply backpressure; keep them bounded. */
59
+ onEvent?: (event: AgentEvent, signal: AbortSignal) => void | Promise<void>;
60
+ }
61
+ export type StopReason = 'max_steps' | 'timeout' | 'cancelled' | 'cost_limit' | 'context_limit' | 'incomplete' | 'error';
62
+ export interface RunMetrics {
63
+ /** Additional delivery-only model turns, within the original budgets (0 or 1). */
64
+ finishRepairs: number;
65
+ compactions?: number;
66
+ providerRetries?: number;
67
+ steps: number;
68
+ durationMs: number;
69
+ usage: Usage;
70
+ workspace: string;
71
+ /** Approximate token costs from the configured model catalog, not an invoice. */
72
+ model: string;
73
+ runId?: string;
74
+ historyPath?: string;
75
+ eventsPath?: string;
76
+ recordingPath?: string;
77
+ /** Auxiliary recording/history persistence failures; delivered output remains available. */
78
+ warnings?: string[];
79
+ }
80
+ export type RunResult<T = string> = RunMetrics & ({
81
+ status: 'completed';
82
+ output: T;
83
+ text: string;
84
+ } | {
85
+ status: StopReason;
86
+ output?: never;
87
+ text: string;
88
+ error?: string;
89
+ partial?: {
90
+ path?: string;
91
+ value: unknown;
92
+ };
93
+ });
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["import type {\n AgentEvent,\n AgentState,\n StreamFn,\n ThinkingLevel,\n BeforeToolCallContext,\n BeforeToolCallResult,\n AfterToolCallContext,\n AfterToolCallResult,\n} from '@earendil-works/pi-agent-core';\nimport type { Models, Usage } from '@earendil-works/pi-ai';\nimport type { RecordingOptions } from './recording.js';\nimport type { DomainOptions, SensitiveData } from './policy.js';\nimport type { BrowserOptions } from './browser.js';\n\nexport interface BrowserUseOptions extends DomainOptions {\n sensitiveData?: SensitiveData;\n /** Anonymous run counters. Disable with false, DO_NOT_TRACK=1 or ANONYMIZED_TELEMETRY=false. */\n telemetry?: boolean;\n /** provider/model ID, e.g. openai/gpt-5.4. No model is silently substituted. */\n model: string;\n browser?: BrowserOptions;\n /** Persistent output directory. Defaults to a new OS temporary directory. */\n workspace?: string;\n /** Supply a Pi collection for custom providers or freshly released model definitions. */\n models?: Models;\n reasoning?: ThinkingLevel;\n /** Opt-in Pi read/write/edit/bash tools. Not a filesystem sandbox. */\n researchTools?: boolean;\n tools?: AgentState['tools'];\n instructions?: string;\n operationTimeoutMs?: number;\n cellTimeoutMs?: number;\n /** Whole model response, including connection setup. Default 300000 ms. */\n modelTimeoutMs?: number;\n /** Whole compaction response. Default 120000 ms; original context survives failure. */\n compactionTimeoutMs?: number;\n maxOutputChars?: number;\n /** Before each tool call. Return {block:true, reason} to deny it. */\n beforeToolCall?: (\n context: BeforeToolCallContext,\n signal?: AbortSignal,\n ) => Promise<BeforeToolCallResult | undefined>;\n /** Advanced transport override; useful for deterministic tests or a model gateway. */\n streamFn?: StreamFn;\n /** Named values to redact from saved text/events. Does not redact screenshots. */\n redact?: string[];\n log?: false | 'pretty' | 'json';\n hookTimeoutMs?: number;\n afterToolCall?: (\n context: AfterToolCallContext,\n signal?: AbortSignal,\n ) => Promise<AfterToolCallResult | undefined>;\n /** Return feedback to reject a schema-valid result and let the agent correct it. */\n validateResult?: (output: unknown, signal?: AbortSignal) => Promise<string | void>;\n /** Restore a versioned transcript. Browser profile and JS heap are separate. */\n historyFile?: string;\n recording?: boolean | RecordingOptions;\n /** Show temporary orange corner brackets on the element just clicked or typed into. Default false. */\n highlightActions?: boolean;\n}\nexport interface RunOptions {\n maxSteps?: number;\n timeoutMs?: number;\n /** Soft threshold: checked between model turns. May exceed by one response. */\n maxCostUsd?: number;\n /** Automatically summarize older context with Pi; false retains hard-stop behavior. */\n compaction?: boolean;\n maxContextChars?: number;\n signal?: AbortSignal;\n /** Best-effort nonblocking observations. Coalesces under load; honor the abort signal. */\n observe?: (event: AgentEvent, signal: AbortSignal) => void | Promise<void>;\n observerTimeoutMs?: number;\n /** Pi lifecycle events. Async listeners apply backpressure; keep them bounded. */\n onEvent?: (event: AgentEvent, signal: AbortSignal) => void | Promise<void>;\n}\nexport type StopReason =\n 'max_steps' | 'timeout' | 'cancelled' | 'cost_limit' | 'context_limit' | 'incomplete' | 'error';\nexport interface RunMetrics {\n /** Additional delivery-only model turns, within the original budgets (0 or 1). */\n finishRepairs: number;\n compactions?: number;\n providerRetries?: number;\n steps: number;\n durationMs: number;\n usage: Usage;\n workspace: string;\n /** Approximate token costs from the configured model catalog, not an invoice. */\n model: string;\n runId?: string;\n historyPath?: string;\n eventsPath?: string;\n recordingPath?: string;\n /** Auxiliary recording/history persistence failures; delivered output remains available. */\n warnings?: string[];\n}\nexport type RunResult<T = string> = RunMetrics &\n (\n | { status: 'completed'; output: T; text: string }\n | {\n status: StopReason;\n output?: never;\n text: string;\n error?: string;\n partial?: { path?: string; value: unknown };\n }\n );\n"]}
@@ -0,0 +1,18 @@
1
+ export interface VideoOptions {
2
+ format?: 'mp4' | 'gif';
3
+ output: string;
4
+ title?: string;
5
+ maxFrames?: number;
6
+ /** Opaque rectangles in original screenshot pixels, applied to every exported frame. Raw capture is retained. */
7
+ redact?: {
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ }[];
13
+ ffmpegPath?: string;
14
+ executablePath?: string;
15
+ signal?: AbortSignal;
16
+ }
17
+ /** Render a shareable cut from captured frames. Export creates a new file; source browser is never touched. */
18
+ export declare function exportRecording(recordingPath: string, options: VideoOptions): Promise<string>;
package/dist/video.js ADDED
@@ -0,0 +1,177 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile, copyFile, lstat, constants, } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { CDP } from './cdp.js';
6
+ import { Page } from './page.js';
7
+ import { openBrowser } from './browser.js';
8
+ function encode(args, executable, signal) {
9
+ return new Promise((resolve, reject) => {
10
+ const child = spawn(executable, args, {
11
+ stdio: ['ignore', 'ignore', 'pipe'],
12
+ ...(signal ? { signal } : {}),
13
+ });
14
+ let stderr = '';
15
+ child.stderr.on('data', (chunk) => {
16
+ stderr = (stderr + chunk).slice(-4000);
17
+ });
18
+ const timer = setTimeout(() => child.kill('SIGKILL'), 120000);
19
+ child.on('error', (error) => {
20
+ clearTimeout(timer);
21
+ reject(new Error(`Video encoder unavailable: ${error.message}. Install ffmpeg or set ffmpegPath.`));
22
+ });
23
+ child.on('close', (code) => {
24
+ clearTimeout(timer);
25
+ code === 0 ? resolve() : reject(new Error(`Video encoding failed (${code}): ${stderr}`));
26
+ });
27
+ });
28
+ }
29
+ const TEMPLATE = `<!doctype html><meta charset="utf-8"><style>
30
+ *{box-sizing:border-box}body{margin:0;background:#101318;color:#eef2f6;font:24px system-ui;width:1440px;height:1060px;overflow:hidden}
31
+ header{height:100px;padding:22px 34px;display:flex;align-items:center;gap:22px}.mark{background:#d8ff62;color:#111;border-radius:12px;padding:8px 12px;font-weight:900;font-size:28px}h1{font-size:24px;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sub{font-size:14px;color:#9da7b7;margin-top:5px}
32
+ #stage{position:relative;margin:0 24px;width:1392px;height:870px;background:#20252c;border-radius:12px;overflow:hidden}#shot{width:100%;height:100%;object-fit:contain}#cursor{position:absolute;width:30px;height:40px;filter:drop-shadow(0 2px 4px #0009);transform:translate(-3px,-2px)}#ring{position:absolute;width:48px;height:48px;border:4px solid #d8ff62;border-radius:50%;transform:translate(-24px,-24px)}.mask{position:absolute;background:#101318}
33
+ footer{height:90px;display:flex;align-items:center;justify-content:space-between;padding:0 34px}#label{font-size:20px}#count{color:#9da7b7;font-size:16px}#progress{position:absolute;bottom:0;height:4px;background:#d8ff62;left:0}
34
+ </style><header><span class="mark">bu</span><div><h1 id="title"></h1><div class="sub">Actual browser recording · edited highlights</div></div></header><div id="stage"><img id="shot"><div id="masks"></div><div id="ring"></div><svg id="cursor" viewBox="0 0 30 40"><path d="M3 2L3 32L11 24L18 38L24 35L17 21L29 21Z" fill="#fff" stroke="#101318" stroke-width="2"/></svg></div><footer><div id="label"></div><div id="count"></div></footer><div id="progress"></div>`;
35
+ /** Render a shareable cut from captured frames. Export creates a new file; source browser is never touched. */
36
+ export async function exportRecording(recordingPath, options) {
37
+ options.signal?.throwIfAborted();
38
+ await lstat(resolve(options.output)).then(() => {
39
+ throw new Error('EEXIST: export output already exists.');
40
+ }, (error) => {
41
+ if (error.code !== 'ENOENT')
42
+ throw error;
43
+ });
44
+ const manifest = JSON.parse(await readFile(recordingPath, 'utf8'));
45
+ if (manifest.version !== 1 || !Array.isArray(manifest.frames) || !manifest.frames.length)
46
+ throw new Error('Recording has no captured frames.');
47
+ if (manifest.frames.length > 10001 || manifest.frames.some((f) => !/^\d{5}\.jpg$/.test(f.file)))
48
+ throw new Error('Invalid recording frame manifest.');
49
+ const count = options.maxFrames ?? 36;
50
+ if (!Number.isSafeInteger(count) || count < 2 || count > 120)
51
+ throw new Error('maxFrames must be between 2 and 120.');
52
+ for (const box of options.redact ?? [])
53
+ if (![box.x, box.y, box.width, box.height].every(Number.isFinite) ||
54
+ box.x < 0 ||
55
+ box.y < 0 ||
56
+ box.width <= 0 ||
57
+ box.height <= 0)
58
+ throw new Error('Invalid redaction rectangle.');
59
+ const format = options.format ?? 'mp4';
60
+ if (!['mp4', 'gif'].includes(format))
61
+ throw new Error('format must be mp4 or gif.');
62
+ const frames = manifest.frames.filter((_frame, i, all) => all.length <= count ||
63
+ new Set(Array.from({ length: count }, (_, j) => Math.round((j * (all.length - 1)) / (count - 1)))).has(i));
64
+ const temp = await mkdtemp(join(tmpdir(), 'bu-video-'));
65
+ let browser;
66
+ let cdp;
67
+ try {
68
+ browser = await openBrowser(options.executablePath ? { executablePath: options.executablePath } : {});
69
+ cdp = await CDP.connect(browser.endpoint);
70
+ const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
71
+ const page = await Page.attach(cdp, targetId);
72
+ await page.cdp('Emulation.setDeviceMetricsOverride', {
73
+ width: 1440,
74
+ height: 1060,
75
+ deviceScaleFactor: 1,
76
+ mobile: false,
77
+ });
78
+ const { frameTree } = await page.cdp('Page.getFrameTree');
79
+ await page.cdp('Page.setDocumentContent', { frameId: frameTree.frame.id, html: TEMPLATE });
80
+ let n = 0;
81
+ let previousCursor = { x: 0, y: 0 };
82
+ for (const [index, frame] of frames.entries()) {
83
+ options.signal?.throwIfAborted();
84
+ const bytes = await readFile(join(dirname(recordingPath), frame.file));
85
+ if (bytes.length > 16000000)
86
+ throw new Error('Recording frame exceeds 16 MB.');
87
+ const image = `data:image/jpeg;base64,${bytes.toString('base64')}`;
88
+ const cursor = frame.cursor;
89
+ // Intermediate positions animate the recorded pointer; footage is never reenacted.
90
+ for (let tween = 0; tween < 3; tween++) {
91
+ const p = (tween + 1) / 3;
92
+ await page.evaluate(async (data) => {
93
+ const shot = document.getElementById('shot');
94
+ shot.src = data.image;
95
+ await shot.decode();
96
+ document.getElementById('title').textContent = data.title;
97
+ document.getElementById('label').textContent = data.label;
98
+ document.getElementById('count').textContent = data.count;
99
+ document.getElementById('progress').style.width = `${data.progress * 100}%`;
100
+ const scale = Math.min(1392 / shot.naturalWidth, 870 / shot.naturalHeight);
101
+ const ox = (1392 - shot.naturalWidth * scale) / 2, oy = (870 - shot.naturalHeight * scale) / 2;
102
+ for (const id of ['cursor', 'ring']) {
103
+ const el = document.getElementById(id);
104
+ el.style.display =
105
+ data.cursor && (id !== 'ring' || data.cursor.click) ? 'block' : 'none';
106
+ el.style.left = `${ox + (data.cursor?.x ?? 0) * scale}px`;
107
+ el.style.top = `${oy + (data.cursor?.y ?? 0) * scale}px`;
108
+ }
109
+ const masks = document.getElementById('masks');
110
+ masks.replaceChildren();
111
+ for (const box of data.redact) {
112
+ const mask = document.createElement('div');
113
+ mask.className = 'mask';
114
+ Object.assign(mask.style, {
115
+ left: `${ox + box.x * scale}px`,
116
+ top: `${oy + box.y * scale}px`,
117
+ width: `${box.width * scale}px`,
118
+ height: `${box.height * scale}px`,
119
+ });
120
+ masks.append(mask);
121
+ }
122
+ }, {
123
+ image,
124
+ title: (options.title ?? manifest.task).slice(0, 180),
125
+ label: frame.label.slice(0, 100),
126
+ count: `${index + 1} / ${frames.length} · ${manifest.status}`,
127
+ progress: (index + 1) / frames.length,
128
+ cursor: cursor
129
+ ? {
130
+ x: previousCursor.x + (cursor.x - previousCursor.x) * p,
131
+ y: previousCursor.y + (cursor.y - previousCursor.y) * p,
132
+ click: cursor.click && tween === 2,
133
+ }
134
+ : null,
135
+ redact: options.redact ?? [],
136
+ });
137
+ await writeFile(join(temp, `${String(n++).padStart(5, '0')}.jpg`), await page.screenshot({ quality: 90 }));
138
+ }
139
+ if (cursor)
140
+ previousCursor = cursor;
141
+ }
142
+ cdp.close();
143
+ cdp = undefined;
144
+ await browser.close();
145
+ browser = undefined;
146
+ const encoded = join(temp, `video.${format}`);
147
+ const filter = format === 'gif'
148
+ ? 'fps=6,scale=960:-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse'
149
+ : 'pad=ceil(iw/2)*2:ceil(ih/2)*2,tpad=stop_mode=clone:stop_duration=1';
150
+ await encode([
151
+ '-hide_banner',
152
+ '-loglevel',
153
+ 'error',
154
+ '-n',
155
+ '-framerate',
156
+ '6',
157
+ '-i',
158
+ join(temp, '%05d.jpg'),
159
+ '-filter_complex',
160
+ filter,
161
+ ...(format === 'mp4'
162
+ ? ['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart']
163
+ : ['-loop', '0']),
164
+ encoded,
165
+ ], options.ffmpegPath ?? 'ffmpeg', options.signal);
166
+ const output = resolve(options.output);
167
+ await mkdir(dirname(output), { recursive: true });
168
+ await copyFile(encoded, output, constants.COPYFILE_EXCL);
169
+ return output;
170
+ }
171
+ finally {
172
+ cdp?.close();
173
+ await browser?.close();
174
+ await rm(temp, { recursive: true, force: true });
175
+ }
176
+ }
177
+ //# sourceMappingURL=video.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"video.js","sourceRoot":"","sources":["../src/video.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from 'node:child_process';\nimport {\n mkdir,\n mkdtemp,\n readFile,\n rm,\n writeFile,\n copyFile,\n lstat,\n constants,\n} from 'node:fs/promises';\nimport { dirname, join, resolve } from 'node:path';\nimport { tmpdir } from 'node:os';\nimport { CDP } from './cdp.js';\nimport { Page } from './page.js';\nimport { openBrowser } from './browser.js';\nimport type { RecordingManifest } from './recording.js';\n\nexport interface VideoOptions {\n format?: 'mp4' | 'gif';\n output: string;\n title?: string;\n maxFrames?: number;\n /** Opaque rectangles in original screenshot pixels, applied to every exported frame. Raw capture is retained. */\n redact?: { x: number; y: number; width: number; height: number }[];\n ffmpegPath?: string;\n executablePath?: string;\n signal?: AbortSignal;\n}\n\nfunction encode(args: string[], executable: string, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n const child = spawn(executable, args, {\n stdio: ['ignore', 'ignore', 'pipe'],\n ...(signal ? { signal } : {}),\n });\n let stderr = '';\n child.stderr.on('data', (chunk: Buffer) => {\n stderr = (stderr + chunk).slice(-4000);\n });\n const timer = setTimeout(() => child.kill('SIGKILL'), 120000);\n child.on('error', (error) => {\n clearTimeout(timer);\n reject(\n new Error(`Video encoder unavailable: ${error.message}. Install ffmpeg or set ffmpegPath.`),\n );\n });\n child.on('close', (code) => {\n clearTimeout(timer);\n code === 0 ? resolve() : reject(new Error(`Video encoding failed (${code}): ${stderr}`));\n });\n });\n}\n\nconst TEMPLATE = `<!doctype html><meta charset=\"utf-8\"><style>\n*{box-sizing:border-box}body{margin:0;background:#101318;color:#eef2f6;font:24px system-ui;width:1440px;height:1060px;overflow:hidden}\nheader{height:100px;padding:22px 34px;display:flex;align-items:center;gap:22px}.mark{background:#d8ff62;color:#111;border-radius:12px;padding:8px 12px;font-weight:900;font-size:28px}h1{font-size:24px;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sub{font-size:14px;color:#9da7b7;margin-top:5px}\n#stage{position:relative;margin:0 24px;width:1392px;height:870px;background:#20252c;border-radius:12px;overflow:hidden}#shot{width:100%;height:100%;object-fit:contain}#cursor{position:absolute;width:30px;height:40px;filter:drop-shadow(0 2px 4px #0009);transform:translate(-3px,-2px)}#ring{position:absolute;width:48px;height:48px;border:4px solid #d8ff62;border-radius:50%;transform:translate(-24px,-24px)}.mask{position:absolute;background:#101318}\nfooter{height:90px;display:flex;align-items:center;justify-content:space-between;padding:0 34px}#label{font-size:20px}#count{color:#9da7b7;font-size:16px}#progress{position:absolute;bottom:0;height:4px;background:#d8ff62;left:0}\n</style><header><span class=\"mark\">bu</span><div><h1 id=\"title\"></h1><div class=\"sub\">Actual browser recording · edited highlights</div></div></header><div id=\"stage\"><img id=\"shot\"><div id=\"masks\"></div><div id=\"ring\"></div><svg id=\"cursor\" viewBox=\"0 0 30 40\"><path d=\"M3 2L3 32L11 24L18 38L24 35L17 21L29 21Z\" fill=\"#fff\" stroke=\"#101318\" stroke-width=\"2\"/></svg></div><footer><div id=\"label\"></div><div id=\"count\"></div></footer><div id=\"progress\"></div>`;\n\n/** Render a shareable cut from captured frames. Export creates a new file; source browser is never touched. */\nexport async function exportRecording(\n recordingPath: string,\n options: VideoOptions,\n): Promise<string> {\n options.signal?.throwIfAborted();\n await lstat(resolve(options.output)).then(\n () => {\n throw new Error('EEXIST: export output already exists.');\n },\n (error: NodeJS.ErrnoException) => {\n if (error.code !== 'ENOENT') throw error;\n },\n );\n const manifest = JSON.parse(await readFile(recordingPath, 'utf8')) as RecordingManifest;\n if (manifest.version !== 1 || !Array.isArray(manifest.frames) || !manifest.frames.length)\n throw new Error('Recording has no captured frames.');\n if (manifest.frames.length > 10001 || manifest.frames.some((f) => !/^\\d{5}\\.jpg$/.test(f.file)))\n throw new Error('Invalid recording frame manifest.');\n const count = options.maxFrames ?? 36;\n if (!Number.isSafeInteger(count) || count < 2 || count > 120)\n throw new Error('maxFrames must be between 2 and 120.');\n for (const box of options.redact ?? [])\n if (\n ![box.x, box.y, box.width, box.height].every(Number.isFinite) ||\n box.x < 0 ||\n box.y < 0 ||\n box.width <= 0 ||\n box.height <= 0\n )\n throw new Error('Invalid redaction rectangle.');\n const format = options.format ?? 'mp4';\n if (!['mp4', 'gif'].includes(format)) throw new Error('format must be mp4 or gif.');\n const frames = manifest.frames.filter(\n (_frame, i, all) =>\n all.length <= count ||\n new Set(\n Array.from({ length: count }, (_, j) => Math.round((j * (all.length - 1)) / (count - 1))),\n ).has(i),\n );\n const temp = await mkdtemp(join(tmpdir(), 'bu-video-'));\n let browser: Awaited<ReturnType<typeof openBrowser>> | undefined;\n let cdp: CDP | undefined;\n try {\n browser = await openBrowser(\n options.executablePath ? { executablePath: options.executablePath } : {},\n );\n cdp = await CDP.connect(browser.endpoint);\n const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });\n const page = await Page.attach(cdp, targetId);\n await page.cdp('Emulation.setDeviceMetricsOverride', {\n width: 1440,\n height: 1060,\n deviceScaleFactor: 1,\n mobile: false,\n });\n const { frameTree } = await page.cdp('Page.getFrameTree');\n await page.cdp('Page.setDocumentContent', { frameId: frameTree.frame.id, html: TEMPLATE });\n let n = 0;\n let previousCursor = { x: 0, y: 0 };\n for (const [index, frame] of frames.entries()) {\n options.signal?.throwIfAborted();\n const bytes = await readFile(join(dirname(recordingPath), frame.file));\n if (bytes.length > 16000000) throw new Error('Recording frame exceeds 16 MB.');\n const image = `data:image/jpeg;base64,${bytes.toString('base64')}`;\n const cursor = frame.cursor;\n // Intermediate positions animate the recorded pointer; footage is never reenacted.\n for (let tween = 0; tween < 3; tween++) {\n const p = (tween + 1) / 3;\n await page.evaluate(\n async (data) => {\n const shot = document.getElementById('shot') as HTMLImageElement;\n shot.src = data.image;\n await shot.decode();\n document.getElementById('title')!.textContent = data.title;\n document.getElementById('label')!.textContent = data.label;\n document.getElementById('count')!.textContent = data.count;\n document.getElementById('progress')!.style.width = `${data.progress * 100}%`;\n const scale = Math.min(1392 / shot.naturalWidth, 870 / shot.naturalHeight);\n const ox = (1392 - shot.naturalWidth * scale) / 2,\n oy = (870 - shot.naturalHeight * scale) / 2;\n for (const id of ['cursor', 'ring']) {\n const el = document.getElementById(id)!;\n el.style.display =\n data.cursor && (id !== 'ring' || data.cursor.click) ? 'block' : 'none';\n el.style.left = `${ox + (data.cursor?.x ?? 0) * scale}px`;\n el.style.top = `${oy + (data.cursor?.y ?? 0) * scale}px`;\n }\n const masks = document.getElementById('masks')!;\n masks.replaceChildren();\n for (const box of data.redact) {\n const mask = document.createElement('div');\n mask.className = 'mask';\n Object.assign(mask.style, {\n left: `${ox + box.x * scale}px`,\n top: `${oy + box.y * scale}px`,\n width: `${box.width * scale}px`,\n height: `${box.height * scale}px`,\n });\n masks.append(mask);\n }\n },\n {\n image,\n title: (options.title ?? manifest.task).slice(0, 180),\n label: frame.label.slice(0, 100),\n count: `${index + 1} / ${frames.length} · ${manifest.status}`,\n progress: (index + 1) / frames.length,\n cursor: cursor\n ? {\n x: previousCursor.x + (cursor.x - previousCursor.x) * p,\n y: previousCursor.y + (cursor.y - previousCursor.y) * p,\n click: cursor.click && tween === 2,\n }\n : null,\n redact: options.redact ?? [],\n },\n );\n await writeFile(\n join(temp, `${String(n++).padStart(5, '0')}.jpg`),\n await page.screenshot({ quality: 90 }),\n );\n }\n if (cursor) previousCursor = cursor;\n }\n cdp.close();\n cdp = undefined;\n await browser.close();\n browser = undefined;\n const encoded = join(temp, `video.${format}`);\n const filter =\n format === 'gif'\n ? 'fps=6,scale=960:-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse'\n : 'pad=ceil(iw/2)*2:ceil(ih/2)*2,tpad=stop_mode=clone:stop_duration=1';\n await encode(\n [\n '-hide_banner',\n '-loglevel',\n 'error',\n '-n',\n '-framerate',\n '6',\n '-i',\n join(temp, '%05d.jpg'),\n '-filter_complex',\n filter,\n ...(format === 'mp4'\n ? ['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart']\n : ['-loop', '0']),\n encoded,\n ],\n options.ffmpegPath ?? 'ffmpeg',\n options.signal,\n );\n const output = resolve(options.output);\n await mkdir(dirname(output), { recursive: true });\n await copyFile(encoded, output, constants.COPYFILE_EXCL);\n return output;\n } finally {\n cdp?.close();\n await browser?.close();\n await rm(temp, { recursive: true, force: true });\n }\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};