From f556fa42a44e34f02dc0f903d75ed0999714e8ad Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 12 Jun 2026 15:26:56 -0400 Subject: [PATCH 1/4] feat(webapp): add @devolutions/web-recorder session capture library Framework-agnostic browser session-recording capture (WebM video via canvas.captureStream + MediaRecorder, plus asciicast terminal) that pushes to the Gateway /jet/jrec/push endpoint. It lives beside the playback packages (shadow-player, multi-video-player) and ships on the same publish-libraries workflow. Intended as the single source of truth to replace the duplicated recorder copies currently in DVLS web and Hub web (consumer migration is a follow-up). Ref: DVLS-14621 --- .github/workflows/publish-libraries.yml | 4 +- webapp/packages/web-recorder/build.ps1 | 19 ++ .../packages/web-recorder/package.dist.json | 34 +++ webapp/packages/web-recorder/package.json | 23 ++ .../web-recorder/src/ascast-v2-recorder.ts | 94 ++++++++ webapp/packages/web-recorder/src/index.ts | 5 + .../web-recorder/src/recordable-session.ts | 5 + .../web-recorder/src/webm-recorder.ts | 226 ++++++++++++++++++ webapp/packages/web-recorder/tsconfig.json | 25 ++ webapp/packages/web-recorder/vite.config.ts | 78 ++++++ webapp/pnpm-lock.yaml | 18 ++ 11 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 webapp/packages/web-recorder/build.ps1 create mode 100644 webapp/packages/web-recorder/package.dist.json create mode 100644 webapp/packages/web-recorder/package.json create mode 100644 webapp/packages/web-recorder/src/ascast-v2-recorder.ts create mode 100644 webapp/packages/web-recorder/src/index.ts create mode 100644 webapp/packages/web-recorder/src/recordable-session.ts create mode 100644 webapp/packages/web-recorder/src/webm-recorder.ts create mode 100644 webapp/packages/web-recorder/tsconfig.json create mode 100644 webapp/packages/web-recorder/vite.config.ts diff --git a/.github/workflows/publish-libraries.yml b/.github/workflows/publish-libraries.yml index 519d8da24..4a2d4a53a 100644 --- a/.github/workflows/publish-libraries.yml +++ b/.github/workflows/publish-libraries.yml @@ -95,7 +95,7 @@ jobs: strategy: fail-fast: false matrix: - library: [ ts-angular-client, multi-video-player, shadow-player ] + library: [ ts-angular-client, multi-video-player, shadow-player, web-recorder ] include: - library: ts-angular-client libpath: ./devolutions-gateway/openapi/ts-angular-client @@ -103,6 +103,8 @@ jobs: libpath: ./webapp/packages/multi-video-player - library: shadow-player libpath: ./webapp/packages/shadow-player + - library: web-recorder + libpath: ./webapp/packages/web-recorder steps: - name: Check out ${{ github.repository }} diff --git a/webapp/packages/web-recorder/build.ps1 b/webapp/packages/web-recorder/build.ps1 new file mode 100644 index 000000000..b88b2a8fc --- /dev/null +++ b/webapp/packages/web-recorder/build.ps1 @@ -0,0 +1,19 @@ +#!/usr/bin/env pwsh + +$ErrorActionPreference = "Stop" + +Push-Location -Path $PSScriptRoot + +try +{ + pnpm install + + pnpm --filter @devolutions/web-recorder... build + + Set-Location -Path ./dist/ + npm pack +} +finally +{ + Pop-Location +} diff --git a/webapp/packages/web-recorder/package.dist.json b/webapp/packages/web-recorder/package.dist.json new file mode 100644 index 000000000..d04f281d4 --- /dev/null +++ b/webapp/packages/web-recorder/package.dist.json @@ -0,0 +1,34 @@ +{ + "name": "@devolutions/web-recorder", + "version": "0.1.0", + "description": "Framework-agnostic browser session-recording capture (WebM video + asciicast terminal) for Devolutions Gateway jrec push", + "type": "module", + "main": "./index.js", + "module": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "import": "./index.js", + "types": "./index.d.ts" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/Devolutions/devolutions-gateway.git" + }, + "keywords": [ + "recording", + "session-recording", + "webm", + "asciicast", + "capture", + "media-recorder" + ], + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/webapp/packages/web-recorder/package.json b/webapp/packages/web-recorder/package.json new file mode 100644 index 000000000..423df5645 --- /dev/null +++ b/webapp/packages/web-recorder/package.json @@ -0,0 +1,23 @@ +{ + "name": "@devolutions/web-recorder", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "devDependencies": { + "rxjs": "^7.8.1", + "typescript": "~5.6.2", + "vite": "^5.4.9", + "vite-plugin-dts": "^4.3.0", + "vite-plugin-static-copy": "^2.3.0" + } +} diff --git a/webapp/packages/web-recorder/src/ascast-v2-recorder.ts b/webapp/packages/web-recorder/src/ascast-v2-recorder.ts new file mode 100644 index 000000000..374b98f47 --- /dev/null +++ b/webapp/packages/web-recorder/src/ascast-v2-recorder.ts @@ -0,0 +1,94 @@ +export interface AssciiCastV2Header { + version: 2; + width: number; + height: number; + timestamp: number; + env: {[key: string]: string}; +} + +export type AsciiCastV2EventCode = 'o' | 'i' | 'r'; + +export type AsciinCastV2Event = [number, 'o' | 'i' | 'r', string]; +export type AsciiCastV2Event = AssciiCastV2Header | AsciinCastV2Event; + +export class AsciiCastV2Recorder { + private startTime = 0; + private websocket: QueuedWebsocket | null = null; + + constructor( + private initConfig: { + wsUrl: URL; + cols: number; + rows: number; + env: {[key: string]: string}; + terminal: { + onServerOutput: (callback: (data: string) => void) => void; + }; + }, + ) {} + + public start() { + const {wsUrl, cols, rows, env, terminal} = this.initConfig; + wsUrl.searchParams.set('fileType', 'asciicast'); + this.websocket = new QueuedWebsocket(wsUrl.toString()); + this.startTime = Date.now(); + this.send({ + version: 2, + timestamp: this.startTime, + width: cols, + height: rows, + env, + }); + + terminal.onServerOutput(data => { + // Convert \n to \r\n for proper terminal recording + const normalizedData = data.replace(/\r?\n/g, '\r\n'); + this.onEvent('o', normalizedData); + }); + } + + public stop() { + this.websocket?.close(); + } + + private onEvent(eventCode: AsciiCastV2EventCode, data: string) { + const date = (Date.now() - this.startTime) / 1000; + this.send([date, eventCode, data]); + } + + private send(data: AsciiCastV2Event | AssciiCastV2Header) { + this.websocket?.send(JSON.stringify(data) + '\n'); + } +} + +class QueuedWebsocket { + private ws: WebSocket; + private queue: string[] = []; + private ready = false; + + constructor(url: string) { + this.ws = new WebSocket(url); + this.ws.onopen = () => { + this.ready = true; + for (const data of this.queue) { + this.ws.send(data); + } + this.queue = []; + }; + } + + public send(data: string) { + if (this.ready) { + this.ws.send(data); + } else { + this.queue.push(data); + } + } + + public close() { + // Only close if not already closing or closed + if (this.ws.readyState !== WebSocket.CLOSING && this.ws.readyState !== WebSocket.CLOSED) { + this.ws.close(); + } + } +} diff --git a/webapp/packages/web-recorder/src/index.ts b/webapp/packages/web-recorder/src/index.ts new file mode 100644 index 000000000..3ab11b15f --- /dev/null +++ b/webapp/packages/web-recorder/src/index.ts @@ -0,0 +1,5 @@ +export {WebMRecorder} from './webm-recorder'; +export type {WebMRecorderOptions, WebMRecorderTelemetryEvent} from './webm-recorder'; +export {AsciiCastV2Recorder} from './ascast-v2-recorder'; +export type {AssciiCastV2Header, AsciiCastV2Event, AsciinCastV2Event, AsciiCastV2EventCode} from './ascast-v2-recorder'; +export type {IRecordableSession} from './recordable-session'; diff --git a/webapp/packages/web-recorder/src/recordable-session.ts b/webapp/packages/web-recorder/src/recordable-session.ts new file mode 100644 index 000000000..eb6fbc2b8 --- /dev/null +++ b/webapp/packages/web-recorder/src/recordable-session.ts @@ -0,0 +1,5 @@ +// The minimal contract a session must satisfy to be recorded. The recorder itself only needs to +// know a recording was requested; richer per-app session shapes (DVLS/Hub) structurally satisfy this. +export interface IRecordableSession { + shouldStartRecording: boolean; +} diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts new file mode 100644 index 000000000..3db871c46 --- /dev/null +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -0,0 +1,226 @@ +import {Observable, Subject} from 'rxjs'; + +const CanvasStreamFPS = 8; +const MediaRecorderRecordInterval = 10; + +// Telemetry is reported through an optional injected hook so the library stays framework-agnostic; +// consumers (DVLS/Hub) map these events onto their own telemetry stack. +export type WebMRecorderTelemetryEvent = 'recording-initialized' | 'recording-stopped'; + +export interface WebMRecorderOptions { + onTelemetry?: (event: WebMRecorderTelemetryEvent) => void; +} + +export class WebMRecorder { + private mediaRecorder: MediaRecorder | null = null; + private ws: WebSocket | null = null; + private subject = new Subject(); + private _isRecording = false; + private stream: MediaStream | null = null; + private canvas: HTMLCanvasElement | null = null; + private animationLoopHandle: ReturnType | null = null; + private isCleaningUp = false; + + private blobQueue: Blob[] = []; + + constructor(private readonly options: WebMRecorderOptions = {}) {} + + get isRecording() { + return this._isRecording; + } + + start(canvas: HTMLCanvasElement, recordingUrl: string): Observable { + // Prevent starting multiple recordings simultaneously + if (this._isRecording || this.isCleaningUp) { + console.warn('[WebMRecorder] Recording already in progress or cleaning up'); + return this.subject.asObservable(); + } + + // Create new Subject for each start cycle since completed Subjects cannot emit + this.subject = new Subject(); + this.canvas = canvas; + if (!this.initializeCapture(canvas)) { + console.error('Failed to initialize capture. Aborting recording.'); + throw new Error('UnableToStartRecording'); + } + return this.startStreaming(recordingUrl); + } + + stop(): void { + if (this.isCleaningUp) { + return; // Prevent circular cleanup calls + } + this.isCleaningUp = true; + + if (this._isRecording && this.mediaRecorder) { + if (this.mediaRecorder.state !== 'inactive') { + this.mediaRecorder.stop(); + } + } + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.close(); + } + + this.cleanupResources(); + this.subject.complete(); + this.isCleaningUp = false; // Reset only after all cleanup is done + + this.options.onTelemetry?.('recording-stopped'); + } + + // Initialize canvas capture stream + private initializeCapture(canvas: HTMLCanvasElement): boolean { + if (!canvas) { + console.error('IronRDP canvas not found'); + return false; + } + + this.options.onTelemetry?.('recording-initialized'); + try { + this.stream = canvas.captureStream(CanvasStreamFPS); + return true; + } catch (error) { + console.error('Failed to initialize canvas capture:', error); + return false; + } + } + + // Start streaming to WebSocket + private startStreaming(recordingUrl: string): Observable { + if (!this.stream) { + console.error('No capture stream initialized'); + this.subject.error('lblCantViewRecording'); + this.subject.complete(); + return this.subject.asObservable(); + } + + this.initializeWebSocket(recordingUrl); + return this.subject.asObservable(); + } + + private initializeWebSocket(recordingUrl: string): void { + this.ws = new WebSocket(recordingUrl + '&fileType=webm'); + this.ws.onopen = this.handleWebSocketOpen.bind(this); + this.ws.onerror = this.handleWebSocketError.bind(this); + this.ws.onclose = this.handleWebSocketClose.bind(this); + } + + private handleWebSocketOpen(): void { + if (!this.stream) { + this.subject.error('lblCantViewRecording'); + this.subject.complete(); + return; + } + + const recorder = new MediaRecorder(this.stream, {mimeType: 'video/webm'}); + this.mediaRecorder = recorder; + + recorder.onstart = this.handleMediaRecorderStart.bind(this); + recorder.ondataavailable = this.handleMediaRecorderDataAvailable.bind(this); + recorder.onstop = this.handleMediaRecorderStop.bind(this); + recorder.onerror = this.handleMediaRecorderError.bind(this); + + recorder.start(MediaRecorderRecordInterval); + + // Flush any queued blobs now that WebSocket is open + if (this.blobQueue.length > 0) { + for (const blob of this.blobQueue) { + this.ws?.send(blob); + } + this.blobQueue.length = 0; + } + } + + private handleWebSocketClose(): void { + if (!this._isRecording) { + this.subject.error('UnableToConnectToTheRecordingServer'); + } + this.subject.complete(); + } + + private handleWebSocketError(event: Event): void { + console.error('[WebMRecorder] WebSocket error:', event); + this.subject.error('ConnectionToTheRecordingServerLost'); + this.subject.complete(); + this.stop(); // Safe to call - stop() guards against circular calls + } + + // Maintain continuous frame capture by drawing transparent pixels + // This is necessary because: + // 1. Remote connections often have static content with no visual updates + // 2. Without regular frame updates, the MediaRecorder may not capture enough frames + // 3. Insufficient frame capture can lead to: + // - Gaps in the recording + // - Black screens during streaming + // Note: While setInterval is not ideal for frame timing, it provides + // a practical solution for maintaining the stream + private handleMediaRecorderStart(): void { + const animationLoop = () => { + const drawEmpty = () => { + const ctx = this.canvas?.getContext('2d'); + if (!ctx) { + return; + } + ctx.globalAlpha = 0; + ctx.fillRect(0, 0, 1, 1); + }; + + return setInterval(drawEmpty, 1000 / CanvasStreamFPS); + }; + this.animationLoopHandle = animationLoop(); + } + + private handleMediaRecorderDataAvailable(event: BlobEvent): void { + if (!event.data || event.data.size === 0) return; + + if (!this._isRecording) { + this._isRecording = true; + this.subject.next(); + } + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(event.data); + } else { + console.warn('[WebMRecorder] WebSocket not ready, buffering data.'); + this.blobQueue.push(event.data); + } + } + + private handleMediaRecorderStop(): void { + if (!this._isRecording) return; + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.close(); + } + + this.cleanupResources(); + this.subject.complete(); + } + + private handleMediaRecorderError(error: Event): void { + console.error('[WebMRecorder] MediaRecorder encountered an error:', error); + this.subject.error('UnableToStartRecording'); + this.subject.complete(); + this.stop(); // Safe to call - stop() guards against circular calls + } + + private cleanupResources(): void { + this._isRecording = false; + + if (this.animationLoopHandle !== null) { + clearInterval(this.animationLoopHandle); + this.animationLoopHandle = null; + } + + if (this.stream) { + this.stream.getTracks().forEach(track => track.stop()); + this.stream = null; + } + + this.mediaRecorder = null; + this.ws = null; + this.blobQueue.length = 0; + // isCleaningUp flag is reset in stop() method to prevent race conditions + } +} diff --git a/webapp/packages/web-recorder/tsconfig.json b/webapp/packages/web-recorder/tsconfig.json new file mode 100644 index 000000000..f68fe7e5b --- /dev/null +++ b/webapp/packages/web-recorder/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "declaration": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/webapp/packages/web-recorder/vite.config.ts b/webapp/packages/web-recorder/vite.config.ts new file mode 100644 index 000000000..a10885683 --- /dev/null +++ b/webapp/packages/web-recorder/vite.config.ts @@ -0,0 +1,78 @@ +import path from 'node:path'; +import {UserConfig, defineConfig} from 'vite'; +import dts from 'vite-plugin-dts'; +import {viteStaticCopy} from 'vite-plugin-static-copy'; + +// Simple deep merge function +function deepMerge(target: Partial, source: T): T { + for (const key in source) { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + target[key] = deepMerge(target[key] || {}, source[key]); + } else { + target[key] = source[key]; + } + } + return target as T; +} + +const DefaultConfig: UserConfig = { + build: { + lib: { + entry: path.resolve(__dirname, 'src/index.ts'), + name: 'WebRecorder', + fileName: 'index', + formats: ['es'], + }, + rollupOptions: { + // rxjs is a peer dependency provided by the consuming app; do not bundle it. + external: ['rxjs'], + output: { + globals: {}, + }, + }, + }, +}; + +const OutDir = { + debug: 'dist', + release: 'dist', +}; + +const staticCopyPlugin = viteStaticCopy({ + targets: [ + { + src: './package.dist.json', + dest: './', + rename: 'package.json', + }, + ], +}); + +const Plugins = { + debug: [ + dts({ + rollupTypes: true, + }), + staticCopyPlugin, + ], + release: [ + dts({ + rollupTypes: true, + }), + staticCopyPlugin, + ], +}; + +export default defineConfig(({mode}) => { + const isDebug = mode === 'debug'; + console.log(`Building in mode ${mode}`); + + const config: UserConfig = deepMerge({}, DefaultConfig); + config.build = { + ...config.build, + outDir: isDebug ? OutDir.debug : OutDir.release, + }; + config.plugins = isDebug ? Plugins.debug : Plugins.release; + + return config; +}); diff --git a/webapp/pnpm-lock.yaml b/webapp/pnpm-lock.yaml index c75145d60..c312e62b1 100644 --- a/webapp/pnpm-lock.yaml +++ b/webapp/pnpm-lock.yaml @@ -257,6 +257,24 @@ importers: specifier: ^2.3.0 version: 2.3.2(vite@5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + packages/web-recorder: + devDependencies: + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + vite: + specifier: ^5.4.9 + version: 5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) + vite-plugin-dts: + specifier: ^4.3.0 + version: 4.5.4(@types/node@22.19.3)(rollup@4.59.0)(typescript@5.6.3)(vite@5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + vite-plugin-static-copy: + specifier: ^2.3.0 + version: 2.3.2(vite@5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + tools/recording-player-tester: dependencies: '@tailwindcss/vite': From 7a3f7734608b1049c0a949f1555a5aae472a2015 Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 12 Jun 2026 16:09:01 -0400 Subject: [PATCH 2/4] fix(web-recorder): drive frames with requestFrame() instead of a captureStream keepalive Use canvas.captureStream(0) (manual-frame mode) plus a timer-driven requestFrame() to push frames at a deterministic cadence, replacing the captureStream(8) + transparent-pixel ("drawEmpty") keepalive. The old keepalive relied on canvas dirty-tracking that stopped emitting frames on Edge 149 (empty/near-empty WebM -> recording-policy session kills) and also mutated the shared 2D context (globalAlpha=0). requestFrame() captures the current pixels regardless of change, so static desktops still produce a continuous stream and the frame count is controlled explicitly. Ref: DVLS-14621 --- .../web-recorder/src/webm-recorder.ts | 52 ++++++++----------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts index 3db871c46..288a43576 100644 --- a/webapp/packages/web-recorder/src/webm-recorder.ts +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -17,8 +17,8 @@ export class WebMRecorder { private subject = new Subject(); private _isRecording = false; private stream: MediaStream | null = null; - private canvas: HTMLCanvasElement | null = null; - private animationLoopHandle: ReturnType | null = null; + private frameTrack: CanvasCaptureMediaStreamTrack | null = null; + private frameTimer: ReturnType | null = null; private isCleaningUp = false; private blobQueue: Blob[] = []; @@ -29,6 +29,7 @@ export class WebMRecorder { return this._isRecording; } + // Combined method for backward compatibility start(canvas: HTMLCanvasElement, recordingUrl: string): Observable { // Prevent starting multiple recordings simultaneously if (this._isRecording || this.isCleaningUp) { @@ -38,7 +39,6 @@ export class WebMRecorder { // Create new Subject for each start cycle since completed Subjects cannot emit this.subject = new Subject(); - this.canvas = canvas; if (!this.initializeCapture(canvas)) { console.error('Failed to initialize capture. Aborting recording.'); throw new Error('UnableToStartRecording'); @@ -78,7 +78,12 @@ export class WebMRecorder { this.options.onTelemetry?.('recording-initialized'); try { - this.stream = canvas.captureStream(CanvasStreamFPS); + // Manual-frame mode (frameRate 0): the browser does NOT auto-capture. We drive frames explicitly + // with requestFrame(), so the cadence is deterministic and independent of the canvas "dirty" + // heuristic — which stopped emitting frames on Edge 149 and produced empty/near-empty WebM. + this.stream = canvas.captureStream(0); + const tracks = this.stream.getVideoTracks(); + this.frameTrack = tracks.length > 0 ? (tracks[0] as CanvasCaptureMediaStreamTrack) : null; return true; } catch (error) { console.error('Failed to initialize canvas capture:', error); @@ -146,29 +151,17 @@ export class WebMRecorder { this.stop(); // Safe to call - stop() guards against circular calls } - // Maintain continuous frame capture by drawing transparent pixels - // This is necessary because: - // 1. Remote connections often have static content with no visual updates - // 2. Without regular frame updates, the MediaRecorder may not capture enough frames - // 3. Insufficient frame capture can lead to: - // - Gaps in the recording - // - Black screens during streaming - // Note: While setInterval is not ideal for frame timing, it provides - // a practical solution for maintaining the stream + // Drive frames explicitly at a fixed cadence. requestFrame() captures the canvas's current pixels + // whether or not they changed, so even a static remote desktop yields a continuous, well-formed + // stream — and the frame count is controlled here rather than via canvas-mutation side effects. private handleMediaRecorderStart(): void { - const animationLoop = () => { - const drawEmpty = () => { - const ctx = this.canvas?.getContext('2d'); - if (!ctx) { - return; - } - ctx.globalAlpha = 0; - ctx.fillRect(0, 0, 1, 1); - }; - - return setInterval(drawEmpty, 1000 / CanvasStreamFPS); - }; - this.animationLoopHandle = animationLoop(); + // Seed one frame immediately so the WebM header + first keyframe flush without waiting a tick. + this.pushFrame(); + this.frameTimer = setInterval(() => this.pushFrame(), 1000 / CanvasStreamFPS); + } + + private pushFrame(): void { + this.frameTrack?.requestFrame(); } private handleMediaRecorderDataAvailable(event: BlobEvent): void { @@ -208,9 +201,9 @@ export class WebMRecorder { private cleanupResources(): void { this._isRecording = false; - if (this.animationLoopHandle !== null) { - clearInterval(this.animationLoopHandle); - this.animationLoopHandle = null; + if (this.frameTimer !== null) { + clearInterval(this.frameTimer); + this.frameTimer = null; } if (this.stream) { @@ -218,6 +211,7 @@ export class WebMRecorder { this.stream = null; } + this.frameTrack = null; this.mediaRecorder = null; this.ws = null; this.blobQueue.length = 0; From 8e4a73bff1e5ce936e5a1258eb6e0513eff3c037 Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 12 Jun 2026 17:34:39 -0400 Subject: [PATCH 3/4] fix(web-recorder): keep captureStream auto mode; use a real-pixel dirty keepalive captureStream(0) + requestFrame() (manual mode) does not feed MediaRecorder in Chromium -- it yields zero frames (verified empirically: 0 bytes vs 68 KB for auto mode), which surfaced as the recorder hanging on "waiting for the first frame". Revert to captureStream(8) (automatic capture) and replace the static-content keepalive's zero-alpha no-op (elided by Edge 149 dirty-tracking -> empty WebM) with a real 1px value change, wrapped in save()/restore() so it cannot leak globalAlpha onto the shared 2D context. Ref: DVLS-14621 --- .../web-recorder/src/webm-recorder.ts | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts index 288a43576..cd21d0218 100644 --- a/webapp/packages/web-recorder/src/webm-recorder.ts +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -17,8 +17,9 @@ export class WebMRecorder { private subject = new Subject(); private _isRecording = false; private stream: MediaStream | null = null; - private frameTrack: CanvasCaptureMediaStreamTrack | null = null; + private canvas: HTMLCanvasElement | null = null; private frameTimer: ReturnType | null = null; + private keepaliveTick = false; private isCleaningUp = false; private blobQueue: Blob[] = []; @@ -39,6 +40,7 @@ export class WebMRecorder { // Create new Subject for each start cycle since completed Subjects cannot emit this.subject = new Subject(); + this.canvas = canvas; if (!this.initializeCapture(canvas)) { console.error('Failed to initialize capture. Aborting recording.'); throw new Error('UnableToStartRecording'); @@ -78,12 +80,10 @@ export class WebMRecorder { this.options.onTelemetry?.('recording-initialized'); try { - // Manual-frame mode (frameRate 0): the browser does NOT auto-capture. We drive frames explicitly - // with requestFrame(), so the cadence is deterministic and independent of the canvas "dirty" - // heuristic — which stopped emitting frames on Edge 149 and produced empty/near-empty WebM. - this.stream = canvas.captureStream(0); - const tracks = this.stream.getVideoTracks(); - this.frameTrack = tracks.length > 0 ? (tracks[0] as CanvasCaptureMediaStreamTrack) : null; + // Automatic capture, throttled to CanvasStreamFPS: the browser emits a frame whenever the canvas + // is modified. (Manual mode — captureStream(0) + requestFrame() — does NOT feed MediaRecorder + // reliably; it produces zero frames in Chromium, so we keep the canvas "dirty" instead.) + this.stream = canvas.captureStream(CanvasStreamFPS); return true; } catch (error) { console.error('Failed to initialize canvas capture:', error); @@ -151,17 +151,27 @@ export class WebMRecorder { this.stop(); // Safe to call - stop() guards against circular calls } - // Drive frames explicitly at a fixed cadence. requestFrame() captures the canvas's current pixels - // whether or not they changed, so even a static remote desktop yields a continuous, well-formed - // stream — and the frame count is controlled here rather than via canvas-mutation side effects. + // captureStream only emits a frame when the canvas is *modified*. Remote desktops are often static, + // so without this nudge the stream stalls (gaps / black screens). setInterval is not ideal for frame + // timing but is a practical keepalive. private handleMediaRecorderStart(): void { - // Seed one frame immediately so the WebM header + first keyframe flush without waiting a tick. - this.pushFrame(); - this.frameTimer = setInterval(() => this.pushFrame(), 1000 / CanvasStreamFPS); + this.frameTimer = setInterval(() => this.keepCanvasLive(), 1000 / CanvasStreamFPS); } - private pushFrame(): void { - this.frameTrack?.requestFrame(); + // Nudge a single corner pixel with a *real* value change every tick. A zero-alpha / no-op draw is + // elided by some engines' dirty-tracking (Edge 149 → empty WebM), so we alternate the pixel value. + // save()/restore() keeps this from leaking state onto the canvas's shared 2D context. + private keepCanvasLive(): void { + const ctx = this.canvas?.getContext('2d'); + if (!ctx) { + return; + } + ctx.save(); + ctx.globalAlpha = 1; + ctx.fillStyle = this.keepaliveTick ? '#000000' : '#000001'; + ctx.fillRect(0, 0, 1, 1); + ctx.restore(); + this.keepaliveTick = !this.keepaliveTick; } private handleMediaRecorderDataAvailable(event: BlobEvent): void { @@ -211,7 +221,7 @@ export class WebMRecorder { this.stream = null; } - this.frameTrack = null; + this.canvas = null; this.mediaRecorder = null; this.ws = null; this.blobQueue.length = 0; From 913391f5dee2e84d5ce4f8adb06e2bdf13e8a080 Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 12 Jun 2026 18:02:33 -0400 Subject: [PATCH 4/4] fix(web-recorder): drive the dirty-keepalive from requestAnimationFrame captureStream only captures a frame when the canvas is composited, and compositing is driven by the rAF/vsync loop -- NOT by setInterval. With static content (no app animation) a setInterval keepalive marks the canvas dirty but it is never presented, so zero frames are captured (verified empirically on a static canvas: setInterval -> 0 frames, rAF -> 15 frames/2s). Run the 1px dirty-nudge inside requestAnimationFrame so the compositor ticks and frames flow even when the recorded canvas is static. Ref: DVLS-14621 --- .../web-recorder/src/webm-recorder.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts index cd21d0218..2583eec3e 100644 --- a/webapp/packages/web-recorder/src/webm-recorder.ts +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -18,7 +18,7 @@ export class WebMRecorder { private _isRecording = false; private stream: MediaStream | null = null; private canvas: HTMLCanvasElement | null = null; - private frameTimer: ReturnType | null = null; + private keepaliveRaf: number | null = null; private keepaliveTick = false; private isCleaningUp = false; @@ -151,17 +151,24 @@ export class WebMRecorder { this.stop(); // Safe to call - stop() guards against circular calls } - // captureStream only emits a frame when the canvas is *modified*. Remote desktops are often static, - // so without this nudge the stream stalls (gaps / black screens). setInterval is not ideal for frame - // timing but is a practical keepalive. + // captureStream only captures a frame when the canvas is *composited*, and compositing is driven by + // the requestAnimationFrame / vsync loop — NOT by setInterval. With static content (no app animation) + // a setInterval keepalive marks the canvas dirty but it is never presented, so zero frames are + // captured (verified empirically: setInterval -> 0 frames, rAF -> frames). Drive the keepalive from + // rAF so the compositor ticks, and make a real change each frame so a fresh frame is always ready. private handleMediaRecorderStart(): void { - this.frameTimer = setInterval(() => this.keepCanvasLive(), 1000 / CanvasStreamFPS); + const tick = (): void => { + this.nudgeCanvas(); + this.keepaliveRaf = requestAnimationFrame(tick); + }; + this.keepaliveRaf = requestAnimationFrame(tick); } - // Nudge a single corner pixel with a *real* value change every tick. A zero-alpha / no-op draw is - // elided by some engines' dirty-tracking (Edge 149 → empty WebM), so we alternate the pixel value. - // save()/restore() keeps this from leaking state onto the canvas's shared 2D context. - private keepCanvasLive(): void { + // Nudge a single corner pixel with a *real* value change. A zero-alpha / no-op draw is elided by + // some engines' dirty-tracking (Edge 149 → empty WebM), so we alternate the pixel value. captureStream + // throttles the actual capture to CanvasStreamFPS regardless of the rAF rate. save()/restore() keeps + // this from leaking state onto the canvas's shared 2D context. + private nudgeCanvas(): void { const ctx = this.canvas?.getContext('2d'); if (!ctx) { return; @@ -211,9 +218,9 @@ export class WebMRecorder { private cleanupResources(): void { this._isRecording = false; - if (this.frameTimer !== null) { - clearInterval(this.frameTimer); - this.frameTimer = null; + if (this.keepaliveRaf !== null) { + cancelAnimationFrame(this.keepaliveRaf); + this.keepaliveRaf = null; } if (this.stream) {