Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9f77416
fix: don't reset isFirstFrame on background transformer destroy
1egoman Oct 10, 2025
c52bff6
feat: ensure that track processor enabled button is always disabled w…
1egoman Oct 13, 2025
38bba10
feat: add ability to change initial track mode in demo
1egoman Oct 13, 2025
0759f27
feat: add special error case handling for `InvalidStateError` in Proc…
1egoman Oct 13, 2025
d00fdba
feat: add track transformer destroy options with willRestart to hint …
1egoman Oct 13, 2025
6f1d1f3
feat: store lastTrackTransformerDestroyOptions to avoid more calls to…
1egoman Oct 13, 2025
fc0ce33
feat: get rid of lastTrackTransformerDestroyOptions and symbol logic
1egoman Oct 13, 2025
4bff928
feat: add lifecyclestate for deduplicating destroy events
1egoman Oct 13, 2025
15ad53a
refactor: get rid of this.cleanup, I think I don't need it anymore
1egoman Oct 13, 2025
8352d14
feat: add media exhausted state
1egoman Oct 13, 2025
4071561
refactor: convert console.logs into log.debugs
1egoman Oct 13, 2025
80aae2d
refactor: remove unused method parameter
1egoman Oct 13, 2025
b0e5fa1
feat: add handleMediaExhausted call when stream processor stream closes
1egoman Oct 13, 2025
a7d842e
docs: add better comment describing complex logic
1egoman Oct 13, 2025
68ddfee
fix: remove media exhausted assign
1egoman Oct 13, 2025
45879a6
fix: make TrackTransformerDestroyOptions optional to be backwards com…
1egoman Oct 13, 2025
82f0f2d
refactor: rename willRestart -> willProcessorRestart
1egoman Oct 13, 2025
9658ec0
docs(changeset): Fix a "black screen flash" which occurs sometimes wh…
1egoman Oct 13, 2025
367d72a
fix: avoid calling switchBackgroundMode since it has its own error ha…
1egoman Oct 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/open-candles-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/track-processors': patch
---

Fix a "black screen flash" which occurs sometimes when restarting the processor wrapper
13 changes: 11 additions & 2 deletions example/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,25 @@ <h2>LiveKit track processor sample</h2>
</button>

<div class="card mt-1 mb-1 mx-n2 p-2">
<div>
<div class="d-flex align-items-center justify-content-between">
<button
id="toggle-track-processor"
class="btn btn-secondary"
class="btn btn-secondary mr-2"
disabled
type="button"
onclick="appActions.toggleTrackProcessorEnabled()"
>
Insert Track Processor
</button>

<div id="initial-mode-wrapper" style="display: none;">
<span class="text-secondary">Initial mode:</span>
<select class="custom-select" id="initial-mode-select" style="width: 200px;">
<option value="disabled">Disabled</option>
<option value="background-blur">Blur</option>
<option value="virtual-background">Virtual Background</option>
</select>
</div>
Comment on lines +109 to +117
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this UI control in the example to change the initial state the background processor is added in:

Image

</div>

<div id="track-processor-modes" style="display: none;">
Expand Down
24 changes: 21 additions & 3 deletions example/sample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,23 @@ const appActions = {
if (state.isBackgroundProcessorEnabled) {
await camTrack.stopProcessor();
state.isBackgroundProcessorEnabled = false;
$("initial-mode-wrapper").style.display = 'block';
} else {
await state.backgroundProcessor.switchTo({ mode: 'disabled' });
$("initial-mode-wrapper").style.display = 'none';
const initialMode = $<HTMLSelectElement>("initial-mode-select").value as BackgroundProcessorOptions['mode'];

switch (initialMode) {
case 'disabled':
await state.backgroundProcessor.switchTo({ mode: 'disabled' });
break;
case 'virtual-background':
await state.backgroundProcessor.switchTo({ mode: 'virtual-background', imagePath: IMAGE_PATH });
break;
case 'background-blur':
await state.backgroundProcessor.switchTo({ mode: 'background-blur' });
break;
}

state.isBackgroundProcessorEnabled = true;
await camTrack.setProcessor(state.backgroundProcessor);
}
Expand Down Expand Up @@ -655,6 +670,8 @@ function setButtonsForState(connected: boolean) {

toRemove.forEach((id) => $(id)?.removeAttribute('disabled'));
toAdd.forEach((id) => $(id)?.setAttribute('disabled', 'true'));

$("initial-mode-wrapper").style.display = connected ? 'block' : 'none';
}

const elementMapping: { [k: string]: MediaDeviceKind } = {
Expand Down Expand Up @@ -716,11 +733,12 @@ function updateButtonsForPublishState() {
}

function updateTrackProcessorModeButtons() {
const toggleTrackProcessorButtonEnabled = currentRoom?.state === ConnectionState.Connected;
if (state.isBackgroundProcessorEnabled) {
setButtonState('toggle-track-processor', 'Remove Track Processor', false, false);
setButtonState('toggle-track-processor', 'Remove Track Processor', false, !toggleTrackProcessorButtonEnabled);
$('track-processor-modes').style.display = 'block';
} else {
setButtonState('toggle-track-processor', 'Insert Track Processor', false, false);
setButtonState('toggle-track-processor', 'Insert Track Processor', false, !toggleTrackProcessorButtonEnabled);
$('track-processor-modes').style.display = 'none';
}

Expand Down
62 changes: 48 additions & 14 deletions src/ProcessorWrapper.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { type ProcessorOptions, type Track, type TrackProcessor } from 'livekit-client';
import { TrackTransformer } from './transformers';
import { TrackTransformer, TrackTransformerDestroyOptions } from './transformers';
import { createCanvas, waitForTrackResolution } from './utils';
import { LoggerNames, getLogger } from './logger';

type ProcessorWrapperLifecycleState = 'idle' | 'initializing' | 'running' | 'media-exhausted' | 'destroying' | 'destroyed';

export interface ProcessorWrapperOptions {
Comment on lines 5 to 8
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ProcessorWrapper had a lot of implicit state checking logic that made it hard to follow, so I refactored it to be a big state machine instead.

/**
* Maximum frame rate for fallback canvas.captureStream implementation
Expand Down Expand Up @@ -81,10 +83,10 @@ export default class ProcessorWrapper<
// FPS control for fallback implementation
private maxFps: number;

private symbol?: Symbol;

private log = getLogger(LoggerNames.ProcessorWrapper);

private lifecycleState: ProcessorWrapperLifecycleState = 'idle';

constructor(
transformer: Transformer,
name: string,
Expand Down Expand Up @@ -146,6 +148,8 @@ export default class ProcessorWrapper<
}

async init(opts: ProcessorOptions<Track.Kind>): Promise<void> {
this.log.debug('Init called');
this.lifecycleState = 'initializing';
await this.setup(opts);

if (!this.canvas) {
Expand All @@ -162,6 +166,7 @@ export default class ProcessorWrapper<
} else {
this.initStreamProcessorPath();
}
this.lifecycleState = 'running';
}

private initStreamProcessorPath() {
Expand All @@ -174,20 +179,20 @@ export default class ProcessorWrapper<
const readableStream = this.processor.readable;
const pipedStream = readableStream.pipeThrough(this.transformer!.transformer!);

const symbol = Symbol('stream');
this.symbol = symbol;

pipedStream
.pipeTo(this.trackGenerator.writable)
// destroy processor if stream finishes
.then(() => this.destroy(symbol))
// if stream finishes, the media to process is exhausted
.then(() => this.handleMediaExhausted())
// destroy processor if stream errors - unless it's an abort error
.catch((e) => {
if (e instanceof DOMException && e.name === 'AbortError') {
this.log.log('stream processor path aborted');
} else if (e instanceof DOMException && e.name === 'InvalidStateError' && e.message === 'Stream closed') {
this.log.log('stream processor underlying stream closed');
this.handleMediaExhausted();
} else {
this.log.error('error when trying to pipe', e);
this.destroy(symbol);
this.destroy();
}
});

Expand Down Expand Up @@ -241,6 +246,7 @@ export default class ProcessorWrapper<

private startRenderLoop() {
if (!this.sourceDummy || !(this.sourceDummy instanceof HTMLVideoElement)) {
this.handleMediaExhausted();
return;
}

Expand All @@ -263,6 +269,7 @@ export default class ProcessorWrapper<
!this.sourceDummy ||
!(this.sourceDummy instanceof HTMLVideoElement)
) {
this.handleMediaExhausted();
return;
}

Expand Down Expand Up @@ -349,7 +356,8 @@ export default class ProcessorWrapper<
}

async restart(opts: ProcessorOptions<Track.Kind>): Promise<void> {
await this.destroy();
this.log.debug('Restart called');
await this.destroy({ willProcessorRestart: true });
await this.init(opts);
}

Expand All @@ -362,11 +370,18 @@ export default class ProcessorWrapper<
await this.transformer.update(options[0]);
}

async destroy(symbol?: Symbol) {
if (symbol && this.symbol !== symbol) {
// If the symbol is provided, we only destroy if it matches the current symbol
/** Called if the media pipeline no longer can read frames to process from the source media */
private async handleMediaExhausted() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice!

this.log.debug('Media was exhausted from source');
if (this.lifecycleState !== 'running') {
return;
}
this.lifecycleState = 'media-exhausted'
await this.cleanup();
}

/** Tears down the media stack logic initialized in initStreamProcessorPath / initFallbackPath */
private async cleanup() {
if (this.useStreamFallback) {
this.processingEnabled = false;
if (this.animationFrameId) {
Expand All @@ -378,9 +393,28 @@ export default class ProcessorWrapper<
}
this.capturedStream?.getTracks().forEach((track) => track.stop());
} else {
// NOTE: closing writableControl below terminates the stream in initStreamProcessorPath /
// calls the .then(...) which calls this.handleMediaExhausted
await this.processor?.writableControl?.close();
this.trackGenerator?.stop();
}
await this.transformer.destroy();
}

async destroy(transformerDestroyOptions: TrackTransformerDestroyOptions = { willProcessorRestart: false }) {
this.log.debug(`Destroy called - lifecycleState=${this.lifecycleState}, transformerDestroyOptions=${JSON.stringify(transformerDestroyOptions)}`);
switch (this.lifecycleState) {
case 'running':
case 'media-exhausted':
this.lifecycleState = 'destroying';

await this.cleanup();

await this.transformer.destroy(transformerDestroyOptions);
this.lifecycleState = 'destroyed';
break;

default:
break;
}
}
}
9 changes: 6 additions & 3 deletions src/transformers/BackgroundTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as vision from '@mediapipe/tasks-vision';
import { getLogger, LoggerNames } from '../logger';
import { dependencies } from '../../package.json';
import VideoTransformer from './VideoTransformer';
import { VideoTransformerInitOptions } from './types';
import { TrackTransformerDestroyOptions, VideoTransformerInitOptions } from './types';

export type SegmenterOptions = Partial<vision.ImageSegmenterOptions['baseOptions']>;

Expand Down Expand Up @@ -90,11 +90,14 @@ export default class BackgroundProcessor extends VideoTransformer<BackgroundOpti
this.gl?.setBackgroundDisabled(this.options.backgroundDisabled ?? false);
}

async destroy() {
async destroy(options?: TrackTransformerDestroyOptions) {
await super.destroy();
await this.imageSegmenter?.close();
this.backgroundImageAndPath = null;
this.isFirstFrame = true;

if (!options?.willProcessorRestart) {
this.isFirstFrame = true;
}
}

async loadAndSetBackground(path: string) {
Expand Down
19 changes: 11 additions & 8 deletions src/transformers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,39 @@ export interface VideoTransformerInitOptions extends TrackTransformerInitOptions
export interface AudioTransformerInitOptions extends TrackTransformerInitOptions {}

export interface VideoTrackTransformer<Options extends Record<string, unknown>>
extends BaseTrackTransformer<VideoTransformerInitOptions, VideoFrame> {
extends BaseTrackTransformer<VideoTransformerInitOptions, VideoFrame, TrackTransformerDestroyOptions> {
init: (options: VideoTransformerInitOptions) => void;
destroy: () => void;
destroy: (options?: TrackTransformerDestroyOptions) => void;
restart: (options: VideoTransformerInitOptions) => void;
transform: (frame: VideoFrame, controller: TransformStreamDefaultController) => void;
transformer?: TransformStream;
update: (options: Options) => void;
}

export interface AudioTrackTransformer<Options extends Record<string, unknown>>
extends BaseTrackTransformer<AudioTransformerInitOptions, AudioData> {
extends BaseTrackTransformer<AudioTransformerInitOptions, AudioData, TrackTransformerDestroyOptions> {
init: (options: AudioTransformerInitOptions) => void;
destroy: () => void;
destroy: (options: TrackTransformerDestroyOptions) => void;
restart: (options: AudioTransformerInitOptions) => void;
transform: (frame: AudioData, controller: TransformStreamDefaultController) => void;
transformer?: TransformStream;
update: (options: Options) => void;
}

export type TrackTransformerDestroyOptions = { willProcessorRestart: boolean };

export type TrackTransformer<Options extends Record<string, unknown>> =
| VideoTrackTransformer<Options>
| AudioTrackTransformer<Options>;

export interface BaseTrackTransformer<
T extends TrackTransformerInitOptions,
InitOpts extends TrackTransformerInitOptions,
DataType extends VideoFrame | AudioData,
DestroyOpts extends TrackTransformerDestroyOptions = TrackTransformerDestroyOptions,
> {
init: (options: T) => void;
destroy: () => void;
restart: (options: T) => void;
init: (options: InitOpts) => void;
destroy: (options: DestroyOpts) => void;
restart: (options: InitOpts) => void;
transform: (frame: DataType, controller: TransformStreamDefaultController) => void;
transformer?: TransformStream;
}