Skip to content

Fix a "black screen flash" which occurs sometimes when restarting a track#111

Merged
1egoman merged 19 commits intomainfrom
black-screen-flash
Oct 22, 2025
Merged

Fix a "black screen flash" which occurs sometimes when restarting a track#111
1egoman merged 19 commits intomainfrom
black-screen-flash

Conversation

@1egoman
Copy link
Copy Markdown
Contributor

@1egoman 1egoman commented Oct 13, 2025

Summary

@lukasIO recently noticed that on 3920c38, there was a visual artifact visible when a track processor was enabled and the track was restarted (one thing that "flip video" button does). Example below:

Screen.Recording.2025-10-09.at.10.10.14.AM.mov

I dug into it and it ended up being an unintended side effect of #96 - the this.isFirstFrame value was being reset on restarts even though the initialization was already done, leading to an errant delay on the first frame that shouldn't happen.

Untitled.mov

Note: the above is in chrome, but was able to replicate the same behavior on chrome, firefox, and safari.

Technical Deep Dive

This was where I started: #107 (comment). When a track is restarted that has a track processor attached via setProcessor, it looks like a few things happen in this order:

  1. The media is detached, causing any media processor pipelines to terminate.
  2. The track processor is restarted (.restart() is called), which then ...
  3. ... calls transformer.destroy() ...
  4. ... and then calls transformer.init().

Unfortunately this has some problems:

init then destroy is not always the same as a restart

First, calling init / destroy when performing a ProcessorWrapper restart means it is impossible to distinguish between calling init and then calling destroy manually or a restart which does these in quick succession. The BackgroundTransformer needs to set this.isFirstFrame to true in a processor wrapper restart scenario but this doesn't need to happen in non processor wrapper restart scenarios.

So, to distinguish between the two cases, I added an options object which is passed to TrackTransformer.destroy that has an option willProcessorRestart indicating if this destroy is part of a restart. This allows the TrackTransformer to adequitely distinguish between the two cases.

Note that there is a concept of a "transformer restart", but I can't quite get this to do what I want. In theory it seems like maybe I could proxy this processor wrapper signal downwards, but I couldn't get it to work after a few hours of playing around locally so I gave up. Also, doing this might be somewhat of a breaking change for any user developed custom track transformers, given that there could be custom logic in destroy / init that restart isn't handling properly if the code path that a processor level restart takes were to be switched, so I'm also hesitant to make that change just generally.

destroy being called more than once

In the above list, item 3 (... calls transformer.destroy() ...) can actually happen MULTIPLE times, due to a longstanding issue. Lukas worked around this here, but the workaround just patches the problem by blocking execution, it doesn't actually solve anything. This means when a "processor wrapper restart" happens, a second destroy call is fired which has the wrong willProcessorRestart value set, resulting in weird behavior.

It turns out this "destroy being called multiple times" issue was because the stream processor path (but not the fallback path) was calling this.destroy after it ran out of frames. So, calling destroy manually would close the processor stream, which then would cause it to terminate, which would then call this.destroy AGAIN! So instead, break up destroy into destroy and cleanup (which just cleans up the media pipeline objects, but doesn't actually officially proxy the destroy down to the transformer), and only call this.cleanup when a media pipeline runs out of frames.

This was being set as part of
#96 to try to avoid a
longer gray flash, but inadvertently this being reset on destroy meant
that if a track processor was reused with multiple cycles of
init/destroy, it would get preserved even though all the mediapipe setup
logic that was happening during the gray flash wouldn't get rerun.
…essorWrapper pipeline

This always happens at the end of processing a stream
Ensure you can't destroy a ProcessorWrapper when it is already destroyed.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Oct 13, 2025

🦋 Changeset detected

Latest commit: 367d72a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@livekit/track-processors Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@1egoman 1egoman changed the title Fix a "black screen flash" which occurs when enabling a track processor Fix a "black screen flash" which occurs sometimes when restarting a track Oct 13, 2025
Comment thread example/index.html
Comment on lines +109 to +117

<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>
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

Comment thread src/ProcessorWrapper.ts
Comment on lines 5 to 8

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

export interface ProcessorWrapperOptions {
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.

@1egoman 1egoman marked this pull request as ready for review October 13, 2025 18:47
@1egoman 1egoman requested a review from lukasIO October 13, 2025 18:47
Copy link
Copy Markdown
Contributor

@lukasIO lukasIO left a comment

Choose a reason for hiding this comment

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

thanks for digging into this!

Comment thread src/ProcessorWrapper.ts
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!

@xianshijing-lk
Copy link
Copy Markdown

Should we land this PR ?

@1egoman
Copy link
Copy Markdown
Contributor Author

1egoman commented Oct 21, 2025

@xianshijing-lk I was going to merge this last week but noticed that there was a bug in the new example code this change introduces only in firefox where buttons are disabled / not clickable sometimes when they should be. I haven't gotten a chance to dig into why this is happening yet.

@1egoman
Copy link
Copy Markdown
Contributor Author

1egoman commented Oct 22, 2025

^ Ok, worked around the issue with the above commit!

@1egoman 1egoman merged commit 3c2dd4b into main Oct 22, 2025
4 checks passed
@1egoman 1egoman deleted the black-screen-flash branch October 22, 2025 14:45
@github-actions github-actions Bot mentioned this pull request Oct 8, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants