pup-recorder 0.0.8

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 (55) hide show
  1. package/Cargo.lock +230 -0
  2. package/Cargo.toml +23 -0
  3. package/LICENSE +26 -0
  4. package/README.md +96 -0
  5. package/build.rs +5 -0
  6. package/build.ts +55 -0
  7. package/build_rust.ts +51 -0
  8. package/dist/cjs/app.cjs +633 -0
  9. package/dist/cjs/app.cjs.map +7 -0
  10. package/dist/cjs/cli.cjs +691 -0
  11. package/dist/cjs/cli.cjs.map +7 -0
  12. package/dist/cjs/index.cjs +781 -0
  13. package/dist/cjs/index.cjs.map +7 -0
  14. package/dist/cli.js +667 -0
  15. package/dist/cli.js.map +7 -0
  16. package/dist/index.d.ts +93 -0
  17. package/dist/index.js +728 -0
  18. package/dist/index.js.map +7 -0
  19. package/package.json +37 -0
  20. package/rust/darwin-arm64.node +0 -0
  21. package/rust/darwin-x64.node +0 -0
  22. package/rust/linux-arm64.node +0 -0
  23. package/rust/linux-x64.node +0 -0
  24. package/src/app.ts +19 -0
  25. package/src/base/abort.ts +75 -0
  26. package/src/base/constants.ts +18 -0
  27. package/src/base/electron.ts +51 -0
  28. package/src/base/encoder.ts +35 -0
  29. package/src/base/env.ts +21 -0
  30. package/src/base/ffmpeg.ts +188 -0
  31. package/src/base/frame_sync.ts +139 -0
  32. package/src/base/image.ts +9 -0
  33. package/src/base/lazy.ts +20 -0
  34. package/src/base/limiter.ts +58 -0
  35. package/src/base/logging.ts +123 -0
  36. package/src/base/noerr.ts +18 -0
  37. package/src/base/parser.ts +12 -0
  38. package/src/base/process.ts +35 -0
  39. package/src/base/proxy.ts +33 -0
  40. package/src/base/record.ts +228 -0
  41. package/src/base/retry.ts +40 -0
  42. package/src/base/stream.ts +74 -0
  43. package/src/base/timing.ts +23 -0
  44. package/src/base/types.ts +19 -0
  45. package/src/cli.ts +6 -0
  46. package/src/common.ts +53 -0
  47. package/src/index.ts +14 -0
  48. package/src/pup.ts +142 -0
  49. package/src/rust/lib.rs +105 -0
  50. package/src/rust/lib.ts +28 -0
  51. package/tsconfig.json +25 -0
  52. package/x265/darwin-arm64 +0 -0
  53. package/x265/darwin-x64 +0 -0
  54. package/x265/linux-arm64 +0 -0
  55. package/x265/linux-x64 +0 -0
@@ -0,0 +1,139 @@
1
+ // Created by Autokaka (qq1909698494@gmail.com) on 2026/02/09.
2
+
3
+ import type { Debugger, Size } from "electron";
4
+
5
+ export const FRAME_SYNC_MARKER_WIDTH = 32;
6
+ export const FRAME_SYNC_MARKER_HEIGHT = 1;
7
+
8
+ export function buildWrapperHTML(targetURL: string, size: Size): string {
9
+ const { width, height } = size;
10
+ return `<!DOCTYPE html>
11
+ <html>
12
+ <head>
13
+ <meta charset="UTF-8">
14
+ <style>
15
+ * { margin: 0; padding: 0; box-sizing: border-box; }
16
+ html, body { width: ${width}px; height: ${height + 1}px; overflow: hidden; }
17
+ #target {
18
+ position: absolute;
19
+ top: 0;
20
+ left: 0;
21
+ width: ${width}px;
22
+ height: ${height}px;
23
+ border: none;
24
+ display: block;
25
+ }
26
+ #stego {
27
+ position: absolute;
28
+ top: ${height}px;
29
+ left: 0;
30
+ width: ${width}px;
31
+ height: 1px;
32
+ display: block;
33
+ image-rendering: pixelated;
34
+ }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <iframe id="target" src="${targetURL}"></iframe>
39
+ <canvas id="stego" width="${width}" height="1"></canvas>
40
+ <script>
41
+ (function() {
42
+ const WIDTH = ${width};
43
+ const MARKER_WIDTH = ${FRAME_SYNC_MARKER_WIDTH};
44
+ const canvas = document.getElementById('stego');
45
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
46
+ let startTime = null;
47
+ let rafId = null;
48
+
49
+ function encodeTimestamp(timestampMs) {
50
+ const imageData = ctx.createImageData(WIDTH, 1);
51
+ const data = imageData.data;
52
+
53
+ const timestampInt = Math.floor(timestampMs) >>> 0;
54
+
55
+ for (let i = 0; i < MARKER_WIDTH; i++) {
56
+ const bit = (timestampInt >>> (MARKER_WIDTH - 1 - i)) & 1;
57
+ const value = bit ? 255 : 0;
58
+ const idx = i * 4;
59
+ data[idx] = value;
60
+ data[idx + 1] = value;
61
+ data[idx + 2] = value;
62
+ data[idx + 3] = 255;
63
+ }
64
+
65
+ for (let i = MARKER_WIDTH; i < WIDTH; i++) {
66
+ const idx = i * 4;
67
+ data[idx] = 0;
68
+ data[idx + 1] = 0;
69
+ data[idx + 2] = 0;
70
+ data[idx + 3] = 255;
71
+ }
72
+
73
+ ctx.putImageData(imageData, 0, 0);
74
+ }
75
+
76
+ function updateLoop() {
77
+ if (startTime === null) return;
78
+ const elapsed = performance.now() - startTime;
79
+ encodeTimestamp(elapsed);
80
+ rafId = requestAnimationFrame(updateLoop);
81
+ }
82
+
83
+ window.__pup_start_recording__ = () => {
84
+ startTime = performance.now();
85
+ encodeTimestamp(0);
86
+ requestAnimationFrame(updateLoop);
87
+ };
88
+
89
+ window.__pup_stop_recording__ = () => {
90
+ if (rafId !== null) {
91
+ cancelAnimationFrame(rafId);
92
+ rafId = null;
93
+ }
94
+ };
95
+ })();
96
+ </script>
97
+ </body>
98
+ </html>`;
99
+ }
100
+
101
+ export function decodeTimestamp(
102
+ bitmap: Buffer,
103
+ size: Size,
104
+ ): number | undefined {
105
+ const { width, height } = size;
106
+ if (width < FRAME_SYNC_MARKER_WIDTH || height < 2) {
107
+ return undefined;
108
+ }
109
+
110
+ const markerRow = height - 1;
111
+
112
+ let timestamp = 0;
113
+ for (let i = 0; i < FRAME_SYNC_MARKER_WIDTH; i++) {
114
+ const pixelIdx = (markerRow * width + i) * 4;
115
+ const r = bitmap[pixelIdx] ?? 0;
116
+ const bit = r > 127 ? 1 : 0;
117
+ timestamp = (timestamp << 1) | bit;
118
+ }
119
+
120
+ timestamp = timestamp >>> 0;
121
+
122
+ if (!Number.isFinite(timestamp) || timestamp < 0 || timestamp > 1e7) {
123
+ return undefined;
124
+ }
125
+
126
+ return timestamp;
127
+ }
128
+
129
+ export function startSync(cdp: Debugger) {
130
+ return cdp.sendCommand("Runtime.evaluate", {
131
+ expression: `window.__pup_start_recording__()`,
132
+ });
133
+ }
134
+
135
+ export function stopSync(cdp: Debugger) {
136
+ return cdp.sendCommand("Runtime.evaluate", {
137
+ expression: `window.__pup_stop_recording__()`,
138
+ });
139
+ }
@@ -0,0 +1,9 @@
1
+ // Created by Autokaka (qq1909698494@gmail.com) on 2026/02/06.
2
+
3
+ import type { NativeImage } from "electron";
4
+
5
+ export function isEmpty(image: NativeImage) {
6
+ const size = image.getSize();
7
+ if (size.width === 0 || size.height === 0) return true;
8
+ return image.isEmpty();
9
+ }
@@ -0,0 +1,20 @@
1
+ // Created by Autokaka (qq1909698494@gmail.com) on 2026/01/30.
2
+
3
+ export class Lazy<T> {
4
+ constructor(readonly makeValue: () => T) {}
5
+
6
+ get value(): T {
7
+ if (!this._initialized) {
8
+ this._value = this.makeValue();
9
+ this._initialized = true;
10
+ }
11
+ return this._value!;
12
+ }
13
+
14
+ get initialized(): boolean {
15
+ return this._initialized;
16
+ }
17
+
18
+ private _initialized = false;
19
+ private _value: T | undefined;
20
+ }
@@ -0,0 +1,58 @@
1
+ // Created by Autokaka (qq1909698494@gmail.com) on 2026/01/30.
2
+
3
+ export class ConcurrencyLimiter {
4
+ private _active = 0;
5
+ private _queue: VoidFunction[] = [];
6
+ private _pending = 0;
7
+ private _ended = false;
8
+
9
+ constructor(readonly maxConcurrency: number) {}
10
+
11
+ get active(): number {
12
+ return this._active;
13
+ }
14
+
15
+ get pending(): number {
16
+ return this._pending;
17
+ }
18
+
19
+ async schedule<T>(fn: () => Promise<T>): Promise<T> {
20
+ if (this._ended) {
21
+ throw new Error("ended");
22
+ }
23
+ return new Promise<T>((resolve, reject) => {
24
+ const run = () => {
25
+ this._active++;
26
+ this._pending--;
27
+ fn()
28
+ .then(resolve)
29
+ .catch(reject)
30
+ .finally(() => {
31
+ this._active--;
32
+ this.next();
33
+ });
34
+ };
35
+ this._pending++;
36
+ if (this._active < this.maxConcurrency) {
37
+ run();
38
+ } else {
39
+ this._queue.push(run);
40
+ }
41
+ });
42
+ }
43
+
44
+ async end() {
45
+ if (!this._ended) {
46
+ this._ended = true;
47
+ while (this._active > 0 || this._pending > 0) {
48
+ await new Promise((resolve) => setTimeout(resolve, 50));
49
+ }
50
+ }
51
+ }
52
+
53
+ private next() {
54
+ if (this._active < this.maxConcurrency && this._queue.length > 0) {
55
+ this._queue.shift()?.();
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,123 @@
1
+ // Created by Autokaka (qq1909698494@gmail.com) on 2026/02/06.
2
+
3
+ import { ChildProcess, type Serializable } from "child_process";
4
+ import { pupLogLevel } from "./constants";
5
+
6
+ export interface LoggerLike {
7
+ debug?(this: void, ...messages: unknown[]): void;
8
+
9
+ info?(this: void, ...messages: unknown[]): void;
10
+
11
+ warn?(this: void, ...messages: unknown[]): void;
12
+
13
+ error?(this: void, ...messages: unknown[]): void;
14
+ }
15
+
16
+ const DEBUG = "<pup@debug>";
17
+ const INFO = "<pup@info>";
18
+ const WARN = "<pup@warn>";
19
+ const ERROR = "<pup@error>";
20
+ const FATAL = "<pup@fatal>";
21
+
22
+ class Logger implements LoggerLike {
23
+ private _impl?: LoggerLike;
24
+
25
+ get impl(): LoggerLike | undefined {
26
+ return this._impl;
27
+ }
28
+
29
+ set impl(value: LoggerLike) {
30
+ const debug = value.debug ?? console.debug;
31
+ const info = value.info ?? console.info;
32
+ const warn = value.warn ?? console.warn;
33
+ const error = value.error ?? console.error;
34
+ this._impl = {
35
+ debug: pupLogLevel >= 3 ? debug : undefined,
36
+ info: pupLogLevel >= 2 ? info : undefined,
37
+ warn: pupLogLevel >= 1 ? warn : undefined,
38
+ error: pupLogLevel >= 0 ? error : undefined,
39
+ };
40
+ }
41
+
42
+ constructor() {
43
+ this.impl = console;
44
+ }
45
+
46
+ debug(...messages: unknown[]): void {
47
+ this.impl?.debug?.(DEBUG, ...messages);
48
+ }
49
+
50
+ info(...messages: unknown[]): void {
51
+ this.impl?.info?.(INFO, ...messages);
52
+ }
53
+
54
+ warn(...messages: unknown[]): void {
55
+ this.impl?.warn?.(WARN, ...messages);
56
+ }
57
+
58
+ error(...messages: unknown[]): void {
59
+ this.impl?.error?.(ERROR, ...messages);
60
+ }
61
+
62
+ fatal(...messages: unknown[]): never {
63
+ this.impl?.error?.(FATAL, ...messages);
64
+ process.exit(1);
65
+ }
66
+
67
+ private dispatch(message: string) {
68
+ if (message.startsWith(DEBUG)) {
69
+ this.debug(message.slice(DEBUG.length + 1));
70
+ } else if (message.startsWith(INFO)) {
71
+ this.info(message.slice(INFO.length + 1));
72
+ } else if (message.startsWith(WARN)) {
73
+ this.warn(message.slice(WARN.length + 1));
74
+ } else if (message.startsWith(ERROR)) {
75
+ this.error(message.slice(ERROR.length + 1));
76
+ } else {
77
+ this.info(message);
78
+ }
79
+ }
80
+
81
+ attach(proc: ChildProcess, name: string) {
82
+ return new Promise<void>((resolve, reject) => {
83
+ this.debug(`${name}.attach`);
84
+ let fatal: string = "";
85
+ const dispatch = (data: Buffer | Serializable) => {
86
+ const message = data.toString();
87
+ if (message.startsWith(FATAL)) {
88
+ fatal += message.slice(FATAL.length + 1);
89
+ } else {
90
+ this.dispatch(message);
91
+ }
92
+ };
93
+ proc.stderr?.on("data", dispatch);
94
+ proc.stdout?.on("data", dispatch);
95
+ proc
96
+ .on("message", dispatch)
97
+ .on("error", (err) => {
98
+ fatal += err.message;
99
+ proc.kill();
100
+ })
101
+ .once("close", (code, signal) => {
102
+ if (code || signal || fatal) {
103
+ fatal ||= `command failed: ${proc.spawnargs.join(" ")}`;
104
+ this.error(`${name}.close`, { code, signal, fatal });
105
+ reject(new Error(fatal));
106
+ } else {
107
+ this.debug(`${name}.close`);
108
+ resolve();
109
+ }
110
+ })
111
+ .on("unhandledRejection", (reason) => {
112
+ this.error(`${name}.unhandled`, reason);
113
+ })
114
+ .on("uncaughtExceptionMonitor", (err) => {
115
+ this.error(`${name}.unhandled`, err);
116
+ });
117
+ });
118
+ }
119
+ }
120
+
121
+ const logger = new Logger();
122
+
123
+ export { logger };
@@ -0,0 +1,18 @@
1
+ // Created by Autokaka (qq1909698494@gmail.com) on 2026/02/24.
2
+
3
+ export function noerr<Fn extends (...args: any[]) => any, D>(
4
+ fn: Fn,
5
+ defaultValue: D,
6
+ ): (...args: Parameters<Fn>) => ReturnType<Fn> | D {
7
+ return (...args) => {
8
+ try {
9
+ const ret = fn(...args);
10
+ if (ret instanceof Promise) {
11
+ return ret.catch(() => defaultValue);
12
+ }
13
+ return ret;
14
+ } catch {
15
+ return defaultValue;
16
+ }
17
+ };
18
+ }
@@ -0,0 +1,12 @@
1
+ // Created by Autokaka (qq1909698494@gmail.com) on 2026/01/30.
2
+
3
+ export function parseNumber(value: unknown): number {
4
+ if (typeof value === "number") {
5
+ return value;
6
+ }
7
+ const num = Number(value);
8
+ if (Number.isNaN(num)) {
9
+ throw new Error(`Value ${value} is not a valid number`);
10
+ }
11
+ return num;
12
+ }
@@ -0,0 +1,35 @@
1
+ // Created by Autokaka (qq1909698494@gmail.com) on 2026/01/30.
2
+
3
+ import { spawn, type ChildProcess, type SpawnOptions } from "child_process";
4
+ import { logger } from "./logging";
5
+
6
+ export const PUP_ARGS_ENV_KEY = "__PUP_ARGS__";
7
+
8
+ export function pargs() {
9
+ const pupArgs = process.env[PUP_ARGS_ENV_KEY];
10
+ if (pupArgs) {
11
+ const args = ["exec", ...process.argv.slice(-1)];
12
+ args.push(...JSON.parse(pupArgs));
13
+ logger.debug("pupargs", args);
14
+ return args;
15
+ }
16
+
17
+ logger.debug("procargv", process.argv);
18
+ return process.argv;
19
+ }
20
+
21
+ export interface ProcessHandle {
22
+ process: ChildProcess;
23
+ wait: Promise<void>;
24
+ }
25
+
26
+ export function exec(cmd: string, options?: SpawnOptions): ProcessHandle {
27
+ const parts = cmd.split(" ").filter((s) => s.length);
28
+ const [command, ...args] = parts;
29
+ if (!command) throw new Error("empty command");
30
+ const proc = spawn(command, args, {
31
+ stdio: "inherit",
32
+ ...options,
33
+ });
34
+ return { process: proc, wait: logger.attach(proc, command) };
35
+ }
@@ -0,0 +1,33 @@
1
+ // Created by Autokaka (qq1909698494@gmail.com) on 2026/02/09.
2
+
3
+ import { session } from "electron";
4
+ import { logger } from "./logging";
5
+
6
+ const TAG = "[Proxy]";
7
+
8
+ export function proxiedUrl(url: string) {
9
+ if (!url.startsWith("http")) {
10
+ return url;
11
+ }
12
+ // Redirect boss.hdslb.com to boss.bilibili.co
13
+ const match = url.match(/^https:\/\/([^-]+)-boss\.hdslb\.com(.*)$/);
14
+ if (match) {
15
+ const [, prefix, path] = match;
16
+ return `http://${prefix}-boss.bilibili.co${path}`;
17
+ }
18
+ return url;
19
+ }
20
+
21
+ export function enableProxy() {
22
+ // Redirect boss.hdslb.com to boss.bilibili.co
23
+ session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
24
+ const url = details.url;
25
+ const proxied = proxiedUrl(url);
26
+ if (proxied === url) {
27
+ return callback({ cancel: false });
28
+ } else {
29
+ logger.debug(TAG, `${url} -> ${proxied}`);
30
+ callback({ cancel: false, redirectURL: proxied });
31
+ }
32
+ });
33
+ }
@@ -0,0 +1,228 @@
1
+ // Created by Autokaka (qq1909698494@gmail.com) on 2026/02/09.
2
+
3
+ import { BrowserWindow, session, type NativeImage } from "electron";
4
+ import { mkdir, writeFile } from "fs/promises";
5
+ import { join } from "path";
6
+ import { FixedBufferWriter } from "../rust/lib";
7
+ import {
8
+ buildWrapperHTML,
9
+ decodeTimestamp,
10
+ startSync,
11
+ stopSync,
12
+ } from "./frame_sync";
13
+ import { isEmpty } from "./image";
14
+ import { logger } from "./logging";
15
+ import { enableProxy, proxiedUrl } from "./proxy";
16
+ import { useRetry } from "./retry";
17
+
18
+ const TAG = "[Record]";
19
+
20
+ export interface RecordOptions {
21
+ outDir: string;
22
+ duration: number;
23
+ fps: number;
24
+ width: number;
25
+ height: number;
26
+ withAlphaChannel: boolean;
27
+ useInnerProxy: boolean;
28
+ }
29
+
30
+ export interface RecordResult {
31
+ options: RecordOptions;
32
+ written: number;
33
+ bgraPath: string;
34
+ }
35
+
36
+ async function loadWindow(source: string, options: RecordOptions) {
37
+ if (!source.startsWith("file://") && !source.match(/^https?:\/\//)) {
38
+ throw new Error("invalid source");
39
+ }
40
+
41
+ const { width, height, useInnerProxy } = options;
42
+
43
+ // Remove X-Frame-Options and CSP headers to allow iframe loading
44
+ // Note: Electron's onHeadersReceived uses REPLACE behavior (not append).
45
+ // Only the last attached listener is used, so no cleanup needed.
46
+ // Ref: https://www.electronjs.org/docs/latest/api/web-request
47
+ session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
48
+ const responseHeaders = { ...details.responseHeaders };
49
+ delete responseHeaders["x-frame-options"];
50
+ delete responseHeaders["X-Frame-Options"];
51
+ delete responseHeaders["content-security-policy"];
52
+ delete responseHeaders["Content-Security-Policy"];
53
+ callback({ cancel: false, responseHeaders });
54
+ });
55
+
56
+ let src = source;
57
+ if (useInnerProxy) {
58
+ src = proxiedUrl(source);
59
+ enableProxy();
60
+ }
61
+
62
+ const win = new BrowserWindow({
63
+ width: width,
64
+ height: height + 1,
65
+ show: false,
66
+ transparent: true,
67
+ backgroundColor: undefined,
68
+ webPreferences: {
69
+ offscreen: true,
70
+ backgroundThrottling: false,
71
+ nodeIntegration: true,
72
+ contextIsolation: false,
73
+ webSecurity: false,
74
+ allowRunningInsecureContent: true,
75
+ experimentalFeatures: true,
76
+ },
77
+ });
78
+
79
+ win.webContents.on("console-message", (event) => {
80
+ if (event.level === "error") {
81
+ logger.error(TAG, "console:", event.message);
82
+ }
83
+ });
84
+
85
+ const wrapperHTML = buildWrapperHTML(src, { width, height });
86
+ const dataURL = `data:text/html;charset=utf-8,${encodeURIComponent(wrapperHTML)}`;
87
+ let token: NodeJS.Timeout | undefined;
88
+
89
+ await new Promise<void>((resolve, reject) => {
90
+ token = setTimeout(() => {
91
+ reject(new Error("load window timeout"));
92
+ }, 20 * 1000);
93
+
94
+ win.webContents.once("did-finish-load", resolve);
95
+
96
+ win.webContents.once("did-fail-load", (_event, code, desc, url) => {
97
+ reject(new Error(`failed to load ${url}: [${code}] ${desc}`));
98
+ });
99
+
100
+ win.webContents.once("render-process-gone", (_event, details) => {
101
+ const { exitCode, reason } = details;
102
+ reject(new Error(`renderer crashed: ${exitCode}, ${reason}`));
103
+ });
104
+
105
+ win.loadURL(dataURL);
106
+ });
107
+ clearTimeout(token);
108
+ return win;
109
+ }
110
+
111
+ export async function record(
112
+ source: string,
113
+ options: RecordOptions,
114
+ ): Promise<void> {
115
+ logger.info(TAG, `progress: 0%`);
116
+ const { outDir, fps, width, height, duration } = options;
117
+
118
+ const win = await useRetry({ fn: loadWindow, maxAttempts: 2 })(
119
+ source,
120
+ options,
121
+ );
122
+
123
+ await mkdir(outDir, { recursive: true });
124
+
125
+ const cdp = win.webContents.debugger;
126
+ cdp.attach("1.3");
127
+
128
+ win.webContents.setFrameRate(fps);
129
+ if (!win.webContents.isPainting()) {
130
+ win.webContents.startPainting();
131
+ }
132
+
133
+ const bgraPath = join(outDir, "output.bgra");
134
+ const total = Math.ceil(fps * duration);
135
+ const frameInterval = 1000 / fps;
136
+ const bufferSize = width * height * 4;
137
+
138
+ const writer = new FixedBufferWriter(bgraPath, bufferSize, fps);
139
+
140
+ let written = 0;
141
+ let lastWrittenTime: number | undefined;
142
+ let progress = 0;
143
+ let frameError: Error | undefined;
144
+ let resolver: (() => void) | undefined;
145
+ let rejecter: ((reason?: unknown) => void) | undefined;
146
+
147
+ const scheduleWrite = (buffer: Buffer) => {
148
+ written++;
149
+ try {
150
+ writer.write(buffer);
151
+ } catch (error) {
152
+ frameError ??= error as Error;
153
+ }
154
+ };
155
+
156
+ const paint = (_e: unknown, _r: unknown, image: NativeImage) => {
157
+ if (frameError) {
158
+ rejecter?.(frameError);
159
+ return;
160
+ }
161
+
162
+ if (written >= total) {
163
+ resolver?.();
164
+ return;
165
+ }
166
+
167
+ if (isEmpty(image)) return;
168
+
169
+ const bitmap = image.toBitmap();
170
+ const currentTime = decodeTimestamp(bitmap, image.getSize());
171
+ if (currentTime === undefined) {
172
+ frameError ??= new Error(`no timestamp @ ${written}`);
173
+ return;
174
+ }
175
+
176
+ const bytesPerRow = width * 4;
177
+ const cropped = bitmap.subarray(0, height * bytesPerRow);
178
+
179
+ if (lastWrittenTime === undefined) {
180
+ scheduleWrite(cropped);
181
+ lastWrittenTime = currentTime;
182
+ return;
183
+ }
184
+
185
+ const timeSinceLastFrame = currentTime - lastWrittenTime;
186
+ if (timeSinceLastFrame < frameInterval * 0.8) {
187
+ return;
188
+ }
189
+
190
+ if (timeSinceLastFrame <= frameInterval * 1.2) {
191
+ scheduleWrite(cropped);
192
+ } else {
193
+ const framesToInsert = Math.round(timeSinceLastFrame / frameInterval);
194
+ for (let i = 0; i < framesToInsert && written < total; i++) {
195
+ scheduleWrite(cropped);
196
+ }
197
+ }
198
+ lastWrittenTime = currentTime;
199
+
200
+ const newProgress = Math.floor((written / total) * 100);
201
+ if (Math.abs(newProgress - progress) > 10) {
202
+ progress = newProgress;
203
+ logger.info(TAG, `progress: ${Math.round(progress)}%`);
204
+ }
205
+ };
206
+
207
+ win.webContents.on("paint", paint);
208
+ await startSync(cdp);
209
+ try {
210
+ await new Promise<void>((r, j) => ([resolver, rejecter] = [r, j]));
211
+ } finally {
212
+ await stopSync(cdp);
213
+ win.webContents.off("paint", paint);
214
+ await writer.close();
215
+ }
216
+
217
+ if (frameError || written === 0) {
218
+ throw frameError ?? new Error("no frames captured");
219
+ }
220
+
221
+ try {
222
+ const result: RecordResult = { options, written, bgraPath };
223
+ await writeFile(join(outDir, "record.json"), JSON.stringify(result));
224
+ logger.info(TAG, `progress: 100%, ${written} frames written`);
225
+ } finally {
226
+ win.close();
227
+ }
228
+ }