A Beauty AR demo usually proves one thing: an effect can run on the developer’s device.
A production join flow has a harder contract. The user still needs to enter the call if effect initialization times out, the GPU context is lost, a low-end device cannot sustain processing, or an asset fails to load.
That distinction helps clarify the role of “AI in the browser.” Real-time computer vision can produce useful beauty filters, makeup, segmentation, avatars, and gesture-driven effects. It cannot decide how much delay is acceptable, whether an altered image may be published without confirmation, or what the application should do when processing fails. Those remain product and engineering decisions.
In this tutorial, we will build a preflight gate with one invariant:
Beauty AR is a candidate output until it has produced frames and the user explicitly chooses to publish it.
If the candidate fails, the clean camera remains available. The call does not become dependent on the effect.
The workflow we want
The browser join sequence will be:
- Acquire the camera.
- Show the clean preview.
- Prepare a Beauty AR candidate off-air.
- Verify that the candidate is producing frames before a deadline.
- Let the user choose Join with effect or Join without effect.
- Publish only the selected stream.
- If the live effect later becomes unhealthy, attempt to replace it with the clean stream.
This is deliberately different from testing whether an API call resolved. A resolved initialization promise does not prove that useful video is reaching the preview.
Tencent RTC’s Beauty AR overview describes real-time capabilities including beauty filters, makeup, stickers, virtual backgrounds, avatars, gesture recognition, and image or video enhancement. Its low-end device optimization guide recommends adapting effects to device tiers, using appropriate performance modes, avoiding expensive segmentation or 3D/GAN effects where necessary, and controlling resolution and frame rate.
Our controller turns those recommendations into an application-level join policy without inventing a universal definition of a “fast device.”
Model the states users can actually encounter
A boolean such as beautyEnabled cannot distinguish loading from failure, qualification, publication, or recovery.
Use explicit phases instead:
type Phase = | "idle" | "acquiring-camera" | "camera-ready" | "preparing-effect" | "qualifying-effect" | "ready-clean" | "ready-effect" | "publishing" | "live-clean" | "live-effect" | "blocked"; type ViewState = { phase: Phase; notice?: string; }; These states produce a straightforward UI contract:
| Phase | User-visible behavior |
|---|---|
acquiring-camera | Disable join and show camera setup progress |
camera-ready | Show the clean preview |
preparing-effect | Keep the clean preview visible; allow cancellation |
qualifying-effect | Indicate that the effect is being checked |
ready-clean | Enable clean join and explain why the effect is unavailable, if relevant |
ready-effect | Show both join choices; do not select the effect silently |
publishing | Prevent duplicate join actions |
live-clean / live-effect | Display which output is currently being sent |
blocked | Explain the camera or publication failure and offer a relevant retry |
The important emotional detail is that “your device could not run this effect” should not be presented as “your device cannot join.” Those are separate capabilities.
Create the reproducible project
Start with a framework-independent TypeScript core. It can later sit behind React, Vue, or another UI.
mkdir beauty-preflight cd beauty-preflight npm init -y npm install -D typescript vitest @types/node npx tsc --init Add these scripts to package.json:
{ "scripts": { "test": "vitest run", "test:watch": "vitest" } } For deterministic tests, the core uses a small Stream value rather than requiring real browser media objects:
// src/preflight.ts export type Stream = { id: string }; export type EffectProfile = { id: string; allowSegmentation: boolean; allow3D: boolean; allowGAN: boolean; }; export interface CameraPort { open(): Promise<Stream>; close(stream: Stream): Promise<void>; } export interface BeautyPort { create(input: Stream, profile: EffectProfile): Promise<Stream>; dispose(stream: Stream): Promise<void>; } export interface FrameProbe { hasProgress(stream: Stream, windowMs: number): Promise<boolean>; } export interface PublisherPort { publish(stream: Stream): Promise<void>; replace(stream: Stream): Promise<void>; unpublish(): Promise<void>; } export type Phase = | "idle" | "acquiring-camera" | "camera-ready" | "preparing-effect" | "qualifying-effect" | "ready-clean" | "ready-effect" | "publishing" | "live-clean" | "live-effect" | "blocked"; export type ViewState = { phase: Phase; notice?: string; }; export type Policy = { prepareDeadlineMs: number; qualificationWindowMs: number; }; CameraPort, BeautyPort, and PublisherPort are application-owned boundaries. They are not names of Tencent RTC APIs. The production adapters should call the documented browser, Beauty AR, and RTC integration surfaces available to your application.
Race the preparation, but clean up the loser
A timeout does not cancel the operation underneath it. If initialization finishes after the application has fallen back, that late result must be disposed rather than attached to the preview.
// src/preflight.ts type DeadlineResult<T> = | { kind: "ok"; value: T } | { kind: "error"; error: unknown } | { kind: "timeout" }; async function beforeDeadline<T>( operation: Promise<T>, deadlineMs: number ): Promise<DeadlineResult<T>> { let timer: ReturnType<typeof setTimeout> | undefined; const timeout = new Promise<DeadlineResult<T>>((resolve) => { timer = setTimeout(() => resolve({ kind: "timeout" }), deadlineMs); }); const settled: Promise<DeadlineResult<T>> = operation.then( (value) => ({ kind: "ok", value }), (error) => ({ kind: "error", error }) ); const result = await Promise.race([settled, timeout]); if (timer) clearTimeout(timer); return result; } The deadline is an application policy, not a Tencent RTC performance guarantee. Choose it from your join experience requirements and verify it on representative devices.
Implement the preflight coordinator
The coordinator retains the clean camera while the effect is prepared. An incrementing epoch invalidates callbacks from cancelled or superseded attempts.
// src/preflight.ts export class BeautyPreflight { private state: ViewState = { phase: "idle" }; private epoch = 0; private raw?: Stream; private candidate?: Stream; constructor( private readonly camera: CameraPort, private readonly beauty: BeautyPort, private readonly probe: FrameProbe, private readonly publisher: PublisherPort, private readonly policy: Policy, private readonly onState: (state: ViewState) => void ) {} get snapshot(): ViewState { return this.state; } private transition(state: ViewState): void { this.state = state; this.onState(state); } async start(profile: EffectProfile): Promise<void> { const attempt = ++this.epoch; this.transition({ phase: "acquiring-camera" }); let raw: Stream; try { raw = await this.camera.open(); } catch { if (attempt !== this.epoch) return; this.transition({ phase: "blocked", notice: "Camera access failed. Check permission or device availability." }); return; } if (attempt !== this.epoch) { await this.camera.close(raw); return; } this.raw = raw; this.transition({ phase: "camera-ready" }); this.transition({ phase: "preparing-effect" }); const pending = this.beauty.create(raw, profile); const prepared = await beforeDeadline( pending, this.policy.prepareDeadlineMs ); if (prepared.kind === "timeout") { // The operation may still resolve. Dispose that late candidate. void pending .then((lateStream) => this.beauty.dispose(lateStream)) .catch(() => undefined); if (attempt === this.epoch) { this.transition({ phase: "ready-clean", notice: "The effect took too long to prepare. You can still join normally." }); } return; } if (prepared.kind === "error") { if (attempt === this.epoch) { this.transition({ phase: "ready-clean", notice: "The effect could not be loaded. You can still join normally." }); } return; } const candidate = prepared.value; if (attempt !== this.epoch) { await this.beauty.dispose(candidate); return; } this.candidate = candidate; this.transition({ phase: "qualifying-effect" }); let healthy = false; try { healthy = await this.probe.hasProgress( candidate, this.policy.qualificationWindowMs ); } catch { healthy = false; } if (attempt !== this.epoch) { await this.beauty.dispose(candidate); if (this.candidate === candidate) this.candidate = undefined; return; } if (!healthy) { await this.beauty.dispose(candidate); this.candidate = undefined; this.transition({ phase: "ready-clean", notice: "The effect preview stopped producing frames. Clean video is available." }); return; } this.transition({ phase: "ready-effect" }); } async chooseClean(): Promise<void> { ++this.epoch; const oldCandidate = this.candidate; this.candidate = undefined; if (oldCandidate) await this.beauty.dispose(oldCandidate); if (this.raw) { this.transition({ phase: "ready-clean" }); } } async join(useEffect: boolean): Promise<void> { if (!this.raw) { this.transition({ phase: "blocked", notice: "The camera is not ready." }); return; } if (useEffect && this.state.phase !== "ready-effect") { this.transition({ phase: "ready-clean", notice: "The requested effect is not ready. Choose clean video or retry it." }); return; } const previous = this.state; const selected = useEffect ? this.candidate : this.raw; if (!selected) { this.transition({ phase: "ready-clean", notice: "No publishable video is available." }); return; } this.transition({ phase: "publishing" }); try { await this.publisher.publish(selected); this.transition({ phase: useEffect ? "live-effect" : "live-clean" }); } catch { this.transition({ ...previous, notice: "Joining failed. Your preview is still available; retry when ready." }); } } async recoverToClean(reason: string): Promise<void> { if (!this.raw) return; try { await this.publisher.replace(this.raw); if (this.candidate) { await this.beauty.dispose(this.candidate); this.candidate = undefined; } this.transition({ phase: "live-clean", notice: `The effect was removed: ${reason}` }); } catch { await this.publisher.unpublish().catch(() => undefined); this.transition({ phase: "blocked", notice: "Video recovery failed. Video was stopped instead of leaving a frozen effect on-air." }); } } } recoverToClean() encodes an important trade-off: if replacing an unhealthy effect fails, this implementation stops video rather than intentionally leaving a frozen or corrupted effect output published. Your application can choose a different policy, but it should be explicit and tested.
Test the failures before connecting a camera
Create src/preflight.test.ts:
import { describe, expect, it, vi } from "vitest"; import { BeautyPreflight, type EffectProfile, type Stream } from "./preflight"; const raw: Stream = { id: "raw" }; const effected: Stream = { id: "effected" }; const profile: EffectProfile = { id: "conservative", allowSegmentation: false, allow3D: false, allowGAN: false }; function deferred<T>() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } function fixture(overrides: Record<string, unknown> = {}) { const states: string[] = []; const camera = { open: vi.fn().mockResolvedValue(raw), close: vi.fn().mockResolvedValue(undefined) }; const beauty = { create: vi.fn().mockResolvedValue(effected), dispose: vi.fnSource:https://dev.to/susiewang/join-the-call-even-when-beauty-ar-fails-build-a-browser-preflight-gate-3mni