Skip to content

Commit 6a43e13

Browse files
⚗️ Capture dirty canvas images
1 parent ad260aa commit 6a43e13

7 files changed

Lines changed: 443 additions & 3 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { collectAsyncCalls, mockClock, registerCleanupTask } from '@datadog/browser-core/test'
2+
import type { Clock } from '@datadog/browser-core/test'
3+
import { ONE_SECOND } from '@datadog/js-core/time'
4+
import type { Tracker } from '../trackers'
5+
import { createCanvasManager } from './canvasManager'
6+
import type { ComputeCanvasImageHash, EmitCanvasImage } from './canvasCapture'
7+
import { computeCanvasImageHash, startCanvasCapture } from './canvasCapture'
8+
9+
describe('startCanvasCapture', () => {
10+
let canvas: HTMLCanvasElement
11+
let canvasManager: ReturnType<typeof createCanvasManager>
12+
let clock: Clock
13+
let computeImageHashSpy: jasmine.Spy<ComputeCanvasImageHash>
14+
let emitCanvasImageSpy: jasmine.Spy<EmitCanvasImage>
15+
let imageBlob: Blob
16+
let toBlobSpy: jasmine.Spy<HTMLCanvasElement['toBlob']>
17+
let tracker: Tracker | undefined
18+
19+
beforeEach(() => {
20+
canvas = document.createElement('canvas')
21+
canvasManager = createCanvasManager()
22+
clock = mockClock()
23+
imageBlob = new Blob(['frame'], { type: 'image/png' })
24+
computeImageHashSpy = jasmine.createSpy().and.resolveTo('frame-hash')
25+
emitCanvasImageSpy = jasmine.createSpy()
26+
toBlobSpy = spyOn(canvas, 'toBlob').and.callFake((callback) => callback(imageBlob))
27+
28+
registerCleanupTask(() => tracker?.stop())
29+
})
30+
31+
it('captures dirty canvases at the configured maximum frame rate', async () => {
32+
tracker = startCanvasCapture(canvasManager, 2, emitCanvasImageSpy, computeImageHashSpy)
33+
canvasManager.markCanvasDirty(canvas)
34+
35+
clock.tick(499)
36+
expect(toBlobSpy).not.toHaveBeenCalled()
37+
38+
clock.tick(1)
39+
await collectAsyncCalls(emitCanvasImageSpy)
40+
41+
expect(toBlobSpy).toHaveBeenCalledOnceWith(jasmine.any(Function), 'image/png')
42+
expect(emitCanvasImageSpy).toHaveBeenCalledOnceWith({
43+
blob: imageBlob,
44+
canvas,
45+
hash: 'frame-hash',
46+
})
47+
expect(canvasManager.isCanvasDirty(canvas)).toBeFalse()
48+
})
49+
50+
it('does not capture clean canvases', () => {
51+
tracker = startCanvasCapture(canvasManager, 1, emitCanvasImageSpy, computeImageHashSpy)
52+
53+
clock.tick(ONE_SECOND)
54+
55+
expect(toBlobSpy).not.toHaveBeenCalled()
56+
})
57+
58+
it('skips a captured image when its hash has not changed', async () => {
59+
tracker = startCanvasCapture(canvasManager, 1, emitCanvasImageSpy, computeImageHashSpy)
60+
61+
canvasManager.markCanvasDirty(canvas)
62+
clock.tick(ONE_SECOND)
63+
await collectAsyncCalls(emitCanvasImageSpy)
64+
65+
canvasManager.markCanvasDirty(canvas)
66+
const secondHash = collectAsyncCalls(computeImageHashSpy, 2)
67+
clock.tick(ONE_SECOND)
68+
await secondHash
69+
await Promise.resolve()
70+
71+
expect(toBlobSpy).toHaveBeenCalledTimes(2)
72+
expect(emitCanvasImageSpy).toHaveBeenCalledTimes(1)
73+
})
74+
75+
it('emits each transition when an image returns to an earlier hash', async () => {
76+
computeImageHashSpy.and.resolveTo('first-hash')
77+
tracker = startCanvasCapture(canvasManager, 1, emitCanvasImageSpy, computeImageHashSpy)
78+
79+
canvasManager.markCanvasDirty(canvas)
80+
clock.tick(ONE_SECOND)
81+
await collectAsyncCalls(emitCanvasImageSpy)
82+
83+
computeImageHashSpy.and.resolveTo('second-hash')
84+
canvasManager.markCanvasDirty(canvas)
85+
const secondImage = collectAsyncCalls(emitCanvasImageSpy, 2)
86+
clock.tick(ONE_SECOND)
87+
await secondImage
88+
89+
computeImageHashSpy.and.resolveTo('first-hash')
90+
canvasManager.markCanvasDirty(canvas)
91+
const thirdImage = collectAsyncCalls(emitCanvasImageSpy, 3)
92+
clock.tick(ONE_SECOND)
93+
await thirdImage
94+
95+
expect(emitCanvasImageSpy.calls.argsFor(1)[0].hash).toBe('second-hash')
96+
expect(emitCanvasImageSpy.calls.argsFor(2)[0].hash).toBe('first-hash')
97+
})
98+
99+
it('keeps changes made while an image capture is in progress dirty', async () => {
100+
let resolveHash!: (hash: string) => void
101+
computeImageHashSpy.and.returnValue(new Promise((resolve) => (resolveHash = resolve)))
102+
tracker = startCanvasCapture(canvasManager, 1, emitCanvasImageSpy, computeImageHashSpy)
103+
104+
canvasManager.markCanvasDirty(canvas)
105+
clock.tick(ONE_SECOND)
106+
canvasManager.markCanvasDirty(canvas)
107+
clock.tick(ONE_SECOND)
108+
109+
expect(toBlobSpy).toHaveBeenCalledTimes(1)
110+
expect(canvasManager.isCanvasDirty(canvas)).toBeTrue()
111+
112+
resolveHash('first-hash')
113+
await collectAsyncCalls(emitCanvasImageSpy)
114+
await Promise.resolve()
115+
clock.tick(ONE_SECOND)
116+
117+
expect(toBlobSpy).toHaveBeenCalledTimes(2)
118+
})
119+
120+
it('ignores captures that do not produce a blob', () => {
121+
toBlobSpy.and.callFake((callback) => callback(null))
122+
tracker = startCanvasCapture(canvasManager, 1, emitCanvasImageSpy, computeImageHashSpy)
123+
canvasManager.markCanvasDirty(canvas)
124+
125+
clock.tick(ONE_SECOND)
126+
127+
expect(computeImageHashSpy).not.toHaveBeenCalled()
128+
expect(emitCanvasImageSpy).not.toHaveBeenCalled()
129+
})
130+
131+
it('stops capturing images and clears dirty canvases', () => {
132+
tracker = startCanvasCapture(canvasManager, 1, emitCanvasImageSpy, computeImageHashSpy)
133+
canvasManager.markCanvasDirty(canvas)
134+
135+
tracker.stop()
136+
clock.tick(ONE_SECOND)
137+
138+
expect(toBlobSpy).not.toHaveBeenCalled()
139+
expect(canvasManager.isCanvasDirty(canvas)).toBeFalse()
140+
})
141+
})
142+
143+
describe('computeCanvasImageHash', () => {
144+
it('computes a SHA-256 hash from the image bytes', async () => {
145+
expect(await computeCanvasImageHash(new Blob(['hello']))).toBe(
146+
'2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'
147+
)
148+
})
149+
})
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { clearInterval, setInterval } from '@datadog/browser-core'
2+
import { ONE_SECOND } from '@datadog/js-core/time'
3+
import type { Tracker } from '../trackers'
4+
import type { CanvasManager } from './canvasManager'
5+
6+
export interface CapturedCanvasImage {
7+
blob: Blob
8+
canvas: HTMLCanvasElement
9+
hash: string
10+
}
11+
12+
export type ComputeCanvasImageHash = (blob: Blob) => Promise<string>
13+
export type EmitCanvasImage = (image: CapturedCanvasImage) => void
14+
15+
export function startCanvasCapture(
16+
canvasManager: CanvasManager,
17+
maxFramesPerSecond: number,
18+
emitCanvasImage: EmitCanvasImage,
19+
computeImageHash: ComputeCanvasImageHash = computeCanvasImageHash
20+
): Tracker {
21+
const canvasesBeingCaptured = new WeakSet<HTMLCanvasElement>()
22+
const lastCanvasHash = new WeakMap<HTMLCanvasElement, string>()
23+
let stopped = false
24+
25+
const captureIntervalId = setInterval(captureDirtyCanvases, ONE_SECOND / maxFramesPerSecond)
26+
27+
function captureDirtyCanvases() {
28+
canvasManager.getDirtyCanvases().forEach((canvas) => {
29+
if (canvasesBeingCaptured.has(canvas)) {
30+
return
31+
}
32+
33+
canvasManager.markCanvasClean(canvas)
34+
canvasesBeingCaptured.add(canvas)
35+
36+
try {
37+
canvas.toBlob((blob) => {
38+
if (stopped || !blob) {
39+
canvasesBeingCaptured.delete(canvas)
40+
return
41+
}
42+
43+
void computeImageHash(blob)
44+
.then((hash) => {
45+
canvasesBeingCaptured.delete(canvas)
46+
47+
if (stopped || lastCanvasHash.get(canvas) === hash) {
48+
return
49+
}
50+
51+
lastCanvasHash.set(canvas, hash)
52+
emitCanvasImage({ blob, canvas, hash })
53+
})
54+
.catch(() => canvasesBeingCaptured.delete(canvas))
55+
}, 'image/png')
56+
} catch {
57+
canvasesBeingCaptured.delete(canvas)
58+
}
59+
})
60+
}
61+
62+
return {
63+
stop: () => {
64+
stopped = true
65+
clearInterval(captureIntervalId)
66+
canvasManager.clearDirtyCanvases()
67+
},
68+
}
69+
}
70+
71+
export async function computeCanvasImageHash(blob: Blob): Promise<string> {
72+
const imageBytes = await readBlobAsArrayBuffer(blob)
73+
const digest = await crypto.subtle.digest('SHA-256', imageBytes)
74+
75+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
76+
}
77+
78+
function readBlobAsArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
79+
if (typeof blob.arrayBuffer === 'function') {
80+
return blob.arrayBuffer()
81+
}
82+
83+
return new Promise((resolve, reject) => {
84+
const reader = new FileReader()
85+
reader.onload = () => resolve(reader.result as ArrayBuffer)
86+
reader.onerror = () => reject(reader.error || new Error('Unable to read canvas image'))
87+
reader.readAsArrayBuffer(blob)
88+
})
89+
}

packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,18 @@ describe('createCanvasManager', () => {
2323

2424
expect(canvasManager.isCanvasDirty(dirtyCanvas)).toBeTrue()
2525
expect(canvasManager.isCanvasDirty(cleanCanvas)).toBeFalse()
26+
expect(canvasManager.getDirtyCanvases()).toEqual([dirtyCanvas])
27+
})
28+
29+
it('clears all dirty canvases', () => {
30+
const canvasManager = createCanvasManager()
31+
const firstCanvas = document.createElement('canvas')
32+
const secondCanvas = document.createElement('canvas')
33+
34+
canvasManager.markCanvasDirty(firstCanvas)
35+
canvasManager.markCanvasDirty(secondCanvas)
36+
canvasManager.clearDirtyCanvases()
37+
38+
expect(canvasManager.getDirtyCanvases()).toEqual([])
2639
})
2740
})

packages/browser-rum/src/domain/record/canvas/canvasManager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
export interface CanvasManager {
2+
clearDirtyCanvases: () => void
3+
getDirtyCanvases: () => HTMLCanvasElement[]
24
isCanvasDirty: (canvas: HTMLCanvasElement) => boolean
35
markCanvasClean: (canvas: HTMLCanvasElement) => void
46
markCanvasDirty: (canvas: HTMLCanvasElement) => void
57
}
68

79
export function createCanvasManager(): CanvasManager {
8-
const dirtyCanvases = new WeakSet<HTMLCanvasElement>()
10+
const dirtyCanvases = new Set<HTMLCanvasElement>()
911

1012
return {
13+
clearDirtyCanvases: () => dirtyCanvases.clear(),
14+
getDirtyCanvases: () => Array.from(dirtyCanvases),
1115
isCanvasDirty: (canvas) => dirtyCanvases.has(canvas),
1216
markCanvasClean: (canvas) => dirtyCanvases.delete(canvas),
1317
markCanvasDirty: (canvas) => dirtyCanvases.add(canvas),

packages/browser-rum/src/domain/record/record.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,31 @@ describe('record', () => {
103103
originalFillRect
104104
)
105105
})
106+
107+
it('captures dirty canvases at the configured maximum frame rate', async () => {
108+
const clock = mockClock()
109+
const canvas = document.createElement('canvas')
110+
const context = canvas.getContext('2d')!
111+
const blob = new Blob(['frame'], { type: 'image/png' })
112+
const capturedImageSpy = jasmine.createSpy()
113+
spyOn(canvas, 'toBlob').and.callFake((callback) => callback(blob))
114+
115+
startRecording({ recordCanvas: true, canvasMaxFramesPerSecond: 1 })
116+
recordApi.canvasImageObservable.subscribe(capturedImageSpy)
117+
context.fillRect(0, 0, 1, 1)
118+
clock.tick(999)
119+
120+
expect(capturedImageSpy).not.toHaveBeenCalled()
121+
122+
clock.tick(1)
123+
await collectAsyncCalls(capturedImageSpy)
124+
125+
expect(capturedImageSpy).toHaveBeenCalledOnceWith({
126+
blob,
127+
canvas,
128+
hash: jasmine.any(String),
129+
})
130+
})
106131
})
107132

108133
it('flushes pending mutation records before taking a full snapshot', async () => {

packages/browser-rum/src/domain/record/record.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { sendToExtension } from '@datadog/browser-core'
1+
import { Observable, sendToExtension } from '@datadog/browser-core'
22
import type { LifeCycle, RumConfiguration, ViewHistory } from '@datadog/browser-rum-core'
33
import * as replayStats from '../replayStats'
44
import type { BrowserRecord } from '../../types'
@@ -24,6 +24,8 @@ import { startFullSnapshots } from './startFullSnapshots'
2424
import type { EmitRecordCallback, EmitStatsCallback } from './record.types'
2525
import { createRecordingScope } from './recordingScope'
2626
import { createCanvasManager } from './canvas/canvasManager'
27+
import type { CapturedCanvasImage } from './canvas/canvasCapture'
28+
import { startCanvasCapture } from './canvas/canvasCapture'
2729

2830
export interface RecordOptions {
2931
emitRecord: EmitRecordCallback
@@ -34,13 +36,15 @@ export interface RecordOptions {
3436
}
3537

3638
export interface RecordAPI {
39+
canvasImageObservable: Observable<CapturedCanvasImage>
3740
stop: () => void
3841
flushMutations: () => void
3942
shadowRootsController: ShadowRootsController
4043
}
4144

4245
export function record(options: RecordOptions): RecordAPI {
4346
const { emitRecord, emitStats, configuration, lifeCycle } = options
47+
const canvasImageObservable = new Observable<CapturedCanvasImage>()
4448
// runtime checks for user options
4549
if (!emitRecord || !emitStats) {
4650
throw new Error('emit functions are required')
@@ -83,10 +87,16 @@ export function record(options: RecordOptions): RecordAPI {
8387
configuration.enableSessionReplayCanvasRecording.maxFramesPerSecond > 0
8488
) {
8589
const canvasManager = createCanvasManager()
86-
trackers.push(trackCanvas2DMutations(canvasManager.markCanvasDirty))
90+
trackers.push(
91+
trackCanvas2DMutations(canvasManager.markCanvasDirty),
92+
startCanvasCapture(canvasManager, configuration.canvasMaxFramesPerSecond, (image) =>
93+
canvasImageObservable.notify(image)
94+
)
95+
)
8796
}
8897

8998
return {
99+
canvasImageObservable,
90100
stop: () => {
91101
shadowRootsController.stop()
92102
trackers.forEach((tracker) => tracker.stop())

0 commit comments

Comments
 (0)