@lightworkai.official/debug-capture 0.19.8 → 0.19.9
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.
- package/README.md +23 -7
- package/dist/debug-capture.js +14 -14
- package/dist/index.mjs +21 -21
- package/dist/replayPack.d.ts +56 -3
- package/dist/replayPictures.d.ts +33 -0
- package/dist/types.d.ts +10 -2
- package/package.json +1 -1
- package/src/replayPack.ts +369 -44
- package/src/replayPictures.ts +200 -0
- package/src/submit.ts +1 -1
- package/src/types.ts +14 -2
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas pictures made lighter, for the browsers that cannot make WebP.
|
|
3
|
+
*
|
|
4
|
+
* The recorder asks for WebP (`dataURLOptions`), and Safari answers every such
|
|
5
|
+
* request with a PNG — the same bytes `toDataURL("image/png")` gives, the
|
|
6
|
+
* quality ignored. A PDF page drawn at a Retina display's size is then a
|
|
7
|
+
* megabyte or more where Chrome's WebP is a tenth of that, and PNG's bytes do
|
|
8
|
+
* not gzip. CIB-5880 (2026-11-02): a 76-second recording of signing a
|
|
9
|
+
* document in Safari was 36 MB compressed against a 4 MB cap, and the report
|
|
10
|
+
* went without it.
|
|
11
|
+
*
|
|
12
|
+
* Over the cap, each large PNG is drawn again and saved as JPEG — but only
|
|
13
|
+
* one that is opaque throughout. JPEG has no transparency, and a canvas drawn
|
|
14
|
+
* over another (a signature layer, a chart on a coloured card) would play
|
|
15
|
+
* back as a solid box hiding what was under it; such a picture stays a PNG.
|
|
16
|
+
*
|
|
17
|
+
* Two places hold a picture: a canvas's `rr_dataURL` attribute, in the full
|
|
18
|
+
* snapshot and on a canvas added after it, and a canvas frame's `drawImage`
|
|
19
|
+
* of a Blob. Both are rewritten here, on the events handed in — the caller's
|
|
20
|
+
* copy, never the recorder's buffers.
|
|
21
|
+
*/
|
|
22
|
+
import { toBase64 } from "./base64";
|
|
23
|
+
import type { ReplayEvent } from "./types";
|
|
24
|
+
|
|
25
|
+
export interface Picture {
|
|
26
|
+
base64: string;
|
|
27
|
+
type: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A picture as JPEG, or null to keep it as it is. */
|
|
31
|
+
export type PictureEncoder = (picture: Picture) => Promise<Picture | null>;
|
|
32
|
+
|
|
33
|
+
/** Below this a PNG is not worth the work: an icon, a blank-ish chart. ~22 KB of bytes. */
|
|
34
|
+
const WORTH_IT = 30_000;
|
|
35
|
+
|
|
36
|
+
const FULL_SNAPSHOT = 2;
|
|
37
|
+
const INCREMENTAL = 3;
|
|
38
|
+
const MUTATION = 0;
|
|
39
|
+
const CANVAS_MUTATION = 9;
|
|
40
|
+
|
|
41
|
+
interface SerializedNode {
|
|
42
|
+
attributes?: Record<string, unknown>;
|
|
43
|
+
childNodes?: SerializedNode[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One place a picture is kept, and how to put a new one there. */
|
|
47
|
+
interface Slot {
|
|
48
|
+
picture: Picture;
|
|
49
|
+
put: (next: Picture) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DATA_URL = /^data:([^;,]+);base64,/;
|
|
53
|
+
|
|
54
|
+
function attributeSlots(node: SerializedNode | undefined, out: Slot[]): void {
|
|
55
|
+
if (!node) return;
|
|
56
|
+
const attributes = node.attributes;
|
|
57
|
+
const url = attributes?.rr_dataURL;
|
|
58
|
+
if (attributes && typeof url === "string") {
|
|
59
|
+
const match = DATA_URL.exec(url);
|
|
60
|
+
if (match) {
|
|
61
|
+
out.push({
|
|
62
|
+
picture: { type: match[1]!, base64: url.slice(match[0].length) },
|
|
63
|
+
put: (next) => (attributes.rr_dataURL = `data:${next.type};base64,${next.base64}`),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const child of node.childNodes ?? []) attributeSlots(child, out);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface BlobArg {
|
|
71
|
+
rr_type?: string;
|
|
72
|
+
type?: string;
|
|
73
|
+
data?: Array<{ rr_type?: string; base64?: string }>;
|
|
74
|
+
args?: unknown[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function frameSlots(commands: unknown, out: Slot[]): void {
|
|
78
|
+
if (!Array.isArray(commands)) return;
|
|
79
|
+
for (const command of commands as Array<{ args?: unknown[] }>) {
|
|
80
|
+
for (const arg of command.args ?? []) {
|
|
81
|
+
const bitmap = arg as BlobArg | null;
|
|
82
|
+
if (bitmap?.rr_type !== "ImageBitmap") continue;
|
|
83
|
+
const blob = bitmap.args?.[0] as BlobArg | undefined;
|
|
84
|
+
const buffer = blob?.rr_type === "Blob" ? blob.data?.[0] : undefined;
|
|
85
|
+
if (!blob || buffer?.rr_type !== "ArrayBuffer" || typeof buffer.base64 !== "string" || typeof blob.type !== "string") continue;
|
|
86
|
+
out.push({
|
|
87
|
+
picture: { type: blob.type, base64: buffer.base64 },
|
|
88
|
+
put: (next) => {
|
|
89
|
+
blob.type = next.type;
|
|
90
|
+
buffer.base64 = next.base64;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every canvas picture in the events, wherever the recorder put it. */
|
|
98
|
+
export function pictureSlots(events: readonly ReplayEvent[]): Slot[] {
|
|
99
|
+
const out: Slot[] = [];
|
|
100
|
+
for (const event of events) {
|
|
101
|
+
const data = event.data as Record<string, unknown> | null;
|
|
102
|
+
if (!data || typeof data !== "object") continue;
|
|
103
|
+
if (event.type === FULL_SNAPSHOT) attributeSlots(data.node as SerializedNode | undefined, out);
|
|
104
|
+
else if (event.type === INCREMENTAL && data.source === MUTATION) {
|
|
105
|
+
for (const add of (data.adds as Array<{ node?: SerializedNode }> | undefined) ?? []) attributeSlots(add.node, out);
|
|
106
|
+
} else if (event.type === INCREMENTAL && data.source === CANVAS_MUTATION) frameSlots(data.commands, out);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Every picture's base64, as often as it occurs — gzip's 32 KB window never sees a picture's earlier copy. */
|
|
112
|
+
export function pictureStrings(events: readonly ReplayEvent[]): string[] {
|
|
113
|
+
return pictureSlots(events).map((slot) => slot.picture.base64);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const heavyPng = (slot: Slot) => slot.picture.type === "image/png" && slot.picture.base64.length >= WORTH_IT;
|
|
117
|
+
|
|
118
|
+
/** Whether there is anything for `lightenPictures` to do — before the work of decoding. */
|
|
119
|
+
export function hasHeavyPngs(events: readonly ReplayEvent[]): boolean {
|
|
120
|
+
return pictureSlots(events).some(heavyPng);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Each large PNG replaced by what `encode` makes of it, when that is smaller.
|
|
125
|
+
* The same picture is encoded once however often it recurs — a page's
|
|
126
|
+
* picture is in every snapshot the recording holds. Returns how many changed.
|
|
127
|
+
* A picture the encoder fails on stays as it was: this never throws.
|
|
128
|
+
*/
|
|
129
|
+
export async function lightenPictures(events: ReplayEvent[], encode: PictureEncoder): Promise<number> {
|
|
130
|
+
const done = new Map<string, Picture | null>();
|
|
131
|
+
let changed = 0;
|
|
132
|
+
for (const slot of pictureSlots(events).filter(heavyPng)) {
|
|
133
|
+
const key = slot.picture.base64;
|
|
134
|
+
if (!done.has(key)) {
|
|
135
|
+
let next: Picture | null = null;
|
|
136
|
+
try {
|
|
137
|
+
next = await encode(slot.picture);
|
|
138
|
+
} catch {
|
|
139
|
+
next = null;
|
|
140
|
+
}
|
|
141
|
+
done.set(key, next && next.base64.length < key.length ? next : null);
|
|
142
|
+
}
|
|
143
|
+
const next = done.get(key);
|
|
144
|
+
if (next) {
|
|
145
|
+
slot.put(next);
|
|
146
|
+
changed += 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return changed;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fromBase64(base64: string): Uint8Array {
|
|
153
|
+
const binary = atob(base64);
|
|
154
|
+
const bytes = new Uint8Array(binary.length);
|
|
155
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
156
|
+
return bytes;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type AnyCanvas = OffscreenCanvas | HTMLCanvasElement;
|
|
160
|
+
|
|
161
|
+
function canvasOf(width: number, height: number): AnyCanvas | null {
|
|
162
|
+
if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
|
|
163
|
+
if (typeof document === "undefined") return null;
|
|
164
|
+
const canvas = document.createElement("canvas");
|
|
165
|
+
canvas.width = width;
|
|
166
|
+
canvas.height = height;
|
|
167
|
+
return canvas;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function jpegOf(canvas: AnyCanvas, quality: number): Promise<Blob | null> {
|
|
171
|
+
if ("convertToBlob" in canvas) return canvas.convertToBlob({ type: "image/jpeg", quality });
|
|
172
|
+
return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The browser's own encoder: the PNG decoded, checked opaque pixel by pixel,
|
|
177
|
+
* and saved as JPEG at `quality`. Null where the browser lacks what it takes
|
|
178
|
+
* (no `createImageBitmap`), for a picture anything shows through, and for a
|
|
179
|
+
* browser that hands back something other than a JPEG.
|
|
180
|
+
*/
|
|
181
|
+
export function browserJpeg(quality: number): PictureEncoder | null {
|
|
182
|
+
if (typeof createImageBitmap !== "function" || (typeof OffscreenCanvas === "undefined" && typeof document === "undefined")) return null;
|
|
183
|
+
return async ({ base64, type }) => {
|
|
184
|
+
const bitmap = await createImageBitmap(new Blob([fromBase64(base64) as BlobPart], { type }));
|
|
185
|
+
try {
|
|
186
|
+
const { width, height } = bitmap;
|
|
187
|
+
const canvas = canvasOf(width, height);
|
|
188
|
+
const ctx = canvas?.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
|
|
189
|
+
if (!canvas || !ctx) return null;
|
|
190
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
191
|
+
const pixels = ctx.getImageData(0, 0, width, height).data;
|
|
192
|
+
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] !== 255) return null;
|
|
193
|
+
const blob = await jpegOf(canvas, quality);
|
|
194
|
+
if (!blob || blob.type !== "image/jpeg") return null;
|
|
195
|
+
return { base64: toBase64(new Uint8Array(await blob.arrayBuffer())), type: "image/jpeg" };
|
|
196
|
+
} finally {
|
|
197
|
+
bitmap.close();
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
package/src/submit.ts
CHANGED
|
@@ -110,7 +110,7 @@ export async function submitBundle(title: string, bundle: DebugBundle): Promise<
|
|
|
110
110
|
let replay: ReplaySummary | undefined;
|
|
111
111
|
const recording = replayOptions();
|
|
112
112
|
if (recording) {
|
|
113
|
-
const packed = await packReplay(trimmed.replay, recording.maxBytes);
|
|
113
|
+
const packed = await packReplay(trimmed.replay, recording.maxBytes, { quality: recording.canvas?.quality });
|
|
114
114
|
replay = packed.summary;
|
|
115
115
|
if ("dataUri" in packed) attachments.push({ dataUri: packed.dataUri, filename: packed.filename });
|
|
116
116
|
}
|
package/src/types.ts
CHANGED
|
@@ -188,7 +188,7 @@ export interface ReplayCapture {
|
|
|
188
188
|
* attachment loads — or say why there is none.
|
|
189
189
|
*/
|
|
190
190
|
export interface ReplaySummary {
|
|
191
|
-
/** Whole seconds covered,
|
|
191
|
+
/** Whole seconds covered by what was attached (or, with `omitted`, by what was recorded). */
|
|
192
192
|
seconds?: number;
|
|
193
193
|
events?: number;
|
|
194
194
|
startedAt?: string;
|
|
@@ -196,8 +196,20 @@ export interface ReplaySummary {
|
|
|
196
196
|
/** Size of the attachment as sent, and whether it is gzip. */
|
|
197
197
|
bytes?: number;
|
|
198
198
|
compressed?: boolean;
|
|
199
|
-
|
|
199
|
+
/*
|
|
200
|
+
* What was given up to fit under the size cap (replayPack), each present
|
|
201
|
+
* only when it happened.
|
|
202
|
+
*/
|
|
203
|
+
/** Canvas pictures left out: the canvases play blank. */
|
|
200
204
|
canvasDropped?: boolean;
|
|
205
|
+
/** Large pictures embedded in the page (a `data:` image, an inlined font) left out. */
|
|
206
|
+
picturesDropped?: boolean;
|
|
207
|
+
/** Long text and styles shortened — only when not even one picture of the page fitted otherwise. */
|
|
208
|
+
simplified?: boolean;
|
|
209
|
+
/** Only part of the recording went: how long the whole was. `seconds` is what was sent. */
|
|
210
|
+
recordedSeconds?: number;
|
|
211
|
+
/** The part sent stops this many seconds before the report was made. */
|
|
212
|
+
endsEarlySeconds?: number;
|
|
201
213
|
/** Present when the host records but this report carries no replay. */
|
|
202
214
|
omitted?: "too-large" | "empty";
|
|
203
215
|
}
|