-
Notifications
You must be signed in to change notification settings - Fork 190
⚗️ Capture dirty canvas images #4951
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| import { collectAsyncCalls, mockClock, registerCleanupTask } from '@datadog/browser-core/test' | ||
| import type { Clock } from '@datadog/browser-core/test' | ||
| import { ONE_SECOND } from '@datadog/js-core/time' | ||
| import type { Tracker } from '../trackers' | ||
| import { createCanvasManager } from './canvasManager' | ||
| import type { ComputeCanvasImageHash, EmitCanvasImage } from './canvasCapture' | ||
| import { computeCanvasImageHash, startCanvasCapture } from './canvasCapture' | ||
|
|
||
| describe('startCanvasCapture', () => { | ||
| let canvas: HTMLCanvasElement | ||
| let canvasManager: ReturnType<typeof createCanvasManager> | ||
| let clock: Clock | ||
| let computeImageHashSpy: jasmine.Spy<ComputeCanvasImageHash> | ||
| let emitCanvasImageSpy: jasmine.Spy<EmitCanvasImage> | ||
| let imageBlob: Blob | ||
| let toBlobSpy: jasmine.Spy<HTMLCanvasElement['toBlob']> | ||
| let tracker: Tracker | undefined | ||
|
|
||
| beforeEach(() => { | ||
| canvas = document.createElement('canvas') | ||
| canvasManager = createCanvasManager() | ||
| clock = mockClock() | ||
| imageBlob = new Blob(['frame'], { type: 'image/png' }) | ||
| computeImageHashSpy = jasmine.createSpy().and.resolveTo('frame-hash') | ||
| emitCanvasImageSpy = jasmine.createSpy() | ||
| toBlobSpy = spyOn(canvas, 'toBlob').and.callFake((callback) => callback(imageBlob)) | ||
|
|
||
| registerCleanupTask(() => tracker?.stop()) | ||
| }) | ||
|
|
||
| it('captures dirty canvases at the configured maximum frame rate', async () => { | ||
| tracker = startCanvasCapture(canvasManager, 2, emitCanvasImageSpy, computeImageHashSpy) | ||
| canvasManager.markCanvasDirty(canvas) | ||
|
|
||
| clock.tick(499) | ||
| expect(toBlobSpy).not.toHaveBeenCalled() | ||
|
|
||
| clock.tick(1) | ||
| await collectAsyncCalls(emitCanvasImageSpy) | ||
|
|
||
| expect(toBlobSpy).toHaveBeenCalledOnceWith(jasmine.any(Function), 'image/png') | ||
| expect(emitCanvasImageSpy).toHaveBeenCalledOnceWith({ | ||
| blob: imageBlob, | ||
| canvas, | ||
| hash: 'frame-hash', | ||
| }) | ||
| expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() | ||
| }) | ||
|
|
||
| it('does not capture clean canvases', () => { | ||
| tracker = startCanvasCapture(canvasManager, 1, emitCanvasImageSpy, computeImageHashSpy) | ||
|
|
||
| clock.tick(ONE_SECOND) | ||
|
|
||
| expect(toBlobSpy).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('skips a captured image when its hash has not changed', async () => { | ||
| tracker = startCanvasCapture(canvasManager, 1, emitCanvasImageSpy, computeImageHashSpy) | ||
|
|
||
| canvasManager.markCanvasDirty(canvas) | ||
| clock.tick(ONE_SECOND) | ||
| await collectAsyncCalls(emitCanvasImageSpy) | ||
|
|
||
| canvasManager.markCanvasDirty(canvas) | ||
| const secondHash = collectAsyncCalls(computeImageHashSpy, 2) | ||
| clock.tick(ONE_SECOND) | ||
| await secondHash | ||
| await Promise.resolve() | ||
|
|
||
| expect(toBlobSpy).toHaveBeenCalledTimes(2) | ||
| expect(emitCanvasImageSpy).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('emits each transition when an image returns to an earlier hash', async () => { | ||
| computeImageHashSpy.and.resolveTo('first-hash') | ||
| tracker = startCanvasCapture(canvasManager, 1, emitCanvasImageSpy, computeImageHashSpy) | ||
|
|
||
| canvasManager.markCanvasDirty(canvas) | ||
| clock.tick(ONE_SECOND) | ||
| await collectAsyncCalls(emitCanvasImageSpy) | ||
|
|
||
| computeImageHashSpy.and.resolveTo('second-hash') | ||
| canvasManager.markCanvasDirty(canvas) | ||
| const secondImage = collectAsyncCalls(emitCanvasImageSpy, 2) | ||
| clock.tick(ONE_SECOND) | ||
| await secondImage | ||
|
|
||
| computeImageHashSpy.and.resolveTo('first-hash') | ||
| canvasManager.markCanvasDirty(canvas) | ||
| const thirdImage = collectAsyncCalls(emitCanvasImageSpy, 3) | ||
| clock.tick(ONE_SECOND) | ||
| await thirdImage | ||
|
|
||
| expect(emitCanvasImageSpy.calls.argsFor(1)[0].hash).toBe('second-hash') | ||
| expect(emitCanvasImageSpy.calls.argsFor(2)[0].hash).toBe('first-hash') | ||
| }) | ||
|
|
||
| it('keeps changes made while an image capture is in progress dirty', async () => { | ||
| let resolveHash!: (hash: string) => void | ||
| computeImageHashSpy.and.returnValue(new Promise((resolve) => (resolveHash = resolve))) | ||
| tracker = startCanvasCapture(canvasManager, 1, emitCanvasImageSpy, computeImageHashSpy) | ||
|
|
||
| canvasManager.markCanvasDirty(canvas) | ||
| clock.tick(ONE_SECOND) | ||
| canvasManager.markCanvasDirty(canvas) | ||
| clock.tick(ONE_SECOND) | ||
|
|
||
| expect(toBlobSpy).toHaveBeenCalledTimes(1) | ||
| expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() | ||
|
|
||
| resolveHash('first-hash') | ||
| await collectAsyncCalls(emitCanvasImageSpy) | ||
| await Promise.resolve() | ||
| clock.tick(ONE_SECOND) | ||
|
|
||
| expect(toBlobSpy).toHaveBeenCalledTimes(2) | ||
| }) | ||
|
|
||
| it('ignores captures that do not produce a blob', () => { | ||
| toBlobSpy.and.callFake((callback) => callback(null)) | ||
| tracker = startCanvasCapture(canvasManager, 1, emitCanvasImageSpy, computeImageHashSpy) | ||
| canvasManager.markCanvasDirty(canvas) | ||
|
|
||
| clock.tick(ONE_SECOND) | ||
|
|
||
| expect(computeImageHashSpy).not.toHaveBeenCalled() | ||
| expect(emitCanvasImageSpy).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('stops capturing images and clears dirty canvases', () => { | ||
| tracker = startCanvasCapture(canvasManager, 1, emitCanvasImageSpy, computeImageHashSpy) | ||
| canvasManager.markCanvasDirty(canvas) | ||
|
|
||
| tracker.stop() | ||
| clock.tick(ONE_SECOND) | ||
|
|
||
| expect(toBlobSpy).not.toHaveBeenCalled() | ||
| expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() | ||
| }) | ||
| }) | ||
|
|
||
| describe('computeCanvasImageHash', () => { | ||
| it('computes a SHA-256 hash from the image bytes', async () => { | ||
| expect(await computeCanvasImageHash(new Blob(['hello']))).toBe( | ||
| '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' | ||
| ) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { clearInterval, setInterval } from '@datadog/browser-core' | ||
| import { ONE_SECOND } from '@datadog/js-core/time' | ||
| import type { Tracker } from '../trackers' | ||
| import type { CanvasManager } from './canvasManager' | ||
|
|
||
| export interface CapturedCanvasImage { | ||
| blob: Blob | ||
| canvas: HTMLCanvasElement | ||
| hash: string | ||
| } | ||
|
|
||
| export type ComputeCanvasImageHash = (blob: Blob) => Promise<string> | ||
| export type EmitCanvasImage = (image: CapturedCanvasImage) => void | ||
|
|
||
| export function startCanvasCapture( | ||
| canvasManager: CanvasManager, | ||
| maxFramesPerSecond: number, | ||
| emitCanvasImage: EmitCanvasImage, | ||
| computeImageHash: ComputeCanvasImageHash = computeCanvasImageHash | ||
| ): Tracker { | ||
| const canvasesBeingCaptured = new WeakSet<HTMLCanvasElement>() | ||
| const lastCanvasHash = new WeakMap<HTMLCanvasElement, string>() | ||
| let stopped = false | ||
|
|
||
| const captureIntervalId = setInterval(captureDirtyCanvases, ONE_SECOND / maxFramesPerSecond) | ||
|
|
||
| function captureDirtyCanvases() { | ||
| canvasManager.getDirtyCanvases().forEach((canvas) => { | ||
| if (canvasesBeingCaptured.has(canvas)) { | ||
| return | ||
| } | ||
|
|
||
| canvasManager.markCanvasClean(canvas) | ||
| canvasesBeingCaptured.add(canvas) | ||
|
|
||
| try { | ||
| canvas.toBlob((blob) => { | ||
| if (stopped || !blob) { | ||
| canvasesBeingCaptured.delete(canvas) | ||
| return | ||
| } | ||
|
|
||
| void computeImageHash(blob) | ||
| .then((hash) => { | ||
| canvasesBeingCaptured.delete(canvas) | ||
|
|
||
| if (stopped || lastCanvasHash.get(canvas) === hash) { | ||
| return | ||
| } | ||
|
|
||
| lastCanvasHash.set(canvas, hash) | ||
| emitCanvasImage({ blob, canvas, hash }) | ||
| }) | ||
| .catch(() => canvasesBeingCaptured.delete(canvas)) | ||
| }, 'image/png') | ||
| } catch { | ||
| canvasesBeingCaptured.delete(canvas) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| return { | ||
| stop: () => { | ||
| stopped = true | ||
| clearInterval(captureIntervalId) | ||
| canvasManager.clearDirtyCanvases() | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| export async function computeCanvasImageHash(blob: Blob): Promise<string> { | ||
| const imageBytes = await readBlobAsArrayBuffer(blob) | ||
| const digest = await crypto.subtle.digest('SHA-256', imageBytes) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
issue: When canvas recording runs on a non-secure HTTP origin or another context without Useful? React with 👍 / 👎. |
||
|
|
||
| return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('') | ||
| } | ||
|
|
||
| function readBlobAsArrayBuffer(blob: Blob): Promise<ArrayBuffer> { | ||
| if (typeof blob.arrayBuffer === 'function') { | ||
| return blob.arrayBuffer() | ||
| } | ||
|
|
||
| return new Promise((resolve, reject) => { | ||
| const reader = new FileReader() | ||
| reader.onload = () => resolve(reader.result as ArrayBuffer) | ||
| reader.onerror = () => reject(reader.error || new Error('Unable to read canvas image')) | ||
| reader.readAsArrayBuffer(blob) | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { sendToExtension } from '@datadog/browser-core' | ||
| import { Observable, sendToExtension } from '@datadog/browser-core' | ||
| import type { LifeCycle, RumConfiguration, ViewHistory } from '@datadog/browser-rum-core' | ||
| import * as replayStats from '../replayStats' | ||
| import type { BrowserRecord } from '../../types' | ||
|
|
@@ -24,6 +24,8 @@ import { startFullSnapshots } from './startFullSnapshots' | |
| import type { EmitRecordCallback, EmitStatsCallback } from './record.types' | ||
| import { createRecordingScope } from './recordingScope' | ||
| import { createCanvasManager } from './canvas/canvasManager' | ||
| import type { CapturedCanvasImage } from './canvas/canvasCapture' | ||
| import { startCanvasCapture } from './canvas/canvasCapture' | ||
|
|
||
| export interface RecordOptions { | ||
| emitRecord: EmitRecordCallback | ||
|
|
@@ -34,13 +36,15 @@ export interface RecordOptions { | |
| } | ||
|
|
||
| export interface RecordAPI { | ||
| canvasImageObservable: Observable<CapturedCanvasImage> | ||
| stop: () => void | ||
| flushMutations: () => void | ||
| shadowRootsController: ShadowRootsController | ||
| } | ||
|
|
||
| export function record(options: RecordOptions): RecordAPI { | ||
| const { emitRecord, emitStats, configuration, lifeCycle } = options | ||
| const canvasImageObservable = new Observable<CapturedCanvasImage>() | ||
| // runtime checks for user options | ||
| if (!emitRecord || !emitStats) { | ||
| throw new Error('emit functions are required') | ||
|
|
@@ -83,10 +87,16 @@ export function record(options: RecordOptions): RecordAPI { | |
| configuration.enableSessionReplayCanvasRecording.maxFramesPerSecond > 0 | ||
| ) { | ||
| const canvasManager = createCanvasManager() | ||
| trackers.push(trackCanvas2DMutations(canvasManager.markCanvasDirty)) | ||
| trackers.push( | ||
| trackCanvas2DMutations(canvasManager.markCanvasDirty), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
issue: If a canvas is painted before Useful? React with 👍 / 👎. |
||
| startCanvasCapture(canvasManager, configuration.canvasMaxFramesPerSecond, (image) => | ||
| canvasImageObservable.notify(image) | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| return { | ||
| canvasImageObservable, | ||
| stop: () => { | ||
| shadowRootsController.stop() | ||
| trackers.forEach((tracker) => tracker.stop()) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue: In applications that use detached canvases for image processing or as scratch buffers, the global 2D-context instrumentation marks those canvases dirty too, and this loop encodes and hashes every one of them even though they have no serialized replay node and cannot be displayed in the replay. Repeated drawing on such buffers can therefore add expensive PNG encoding and SHA-256 work at every sampling interval. Gate capture on the canvas being represented in the recording scope, while deferring newly drawn canvases until they are serialized.
Useful? React with 👍 / 👎.