A Beauty AR demo usually has one comfortable sequence: open the camera, select an effect, and admire the result.
Production users are less cooperative. They tap three effects quickly. They withdraw consent while an effect is loading. A low-end device cannot sustain a segmentation or GAN workload. An asynchronous callback then completes late and applies something the user already turned off.
That creates a tension bigger than rendering: who is authoritative—the computer-vision pipeline or the person looking at the preview?
The answer should be encoded, not implied. In this tutorial, we will build a small TypeScript control plane where:
- the latest user intent wins;
- consent withdrawal immediately hides processed output;
- expensive effects can resolve to an explicit fallback;
- stale asynchronous work cannot become the final state;
- adapter failures return to unmodified video instead of leaving the UI ambiguous.
Tencent RTC Beauty AR supports real-time capabilities including beauty filters, makeup, stickers, virtual backgrounds, avatars, gesture recognition, and image/video enhancement. Its official documentation also recommends adapting expensive segmentation, 3D, and GAN effects for constrained devices. See the Beauty AR overview and Low-End Device Performance Optimization Practice Guide.
This tutorial deliberately does not invent SDK method names. We will build an application-owned coordinator with a narrow adapter that you can map to the documented Beauty AR integration used by your project.
The failure timeline
Consider this sequence:
T0 User selects a GAN effect T1 The app starts preparing its resources T2 User taps “Off” T3 The UI changes to Off T4 The old preparation finishes T5 The GAN effect is applied anyway A boolean such as beautyEnabled cannot represent this correctly. It says nothing about which request is current, whether work is in flight, or whether processed frames may still be published.
We need two independent controls:
- Intent control: a monotonically increasing version makes old requests stale.
- Output control: turning an effect off or withdrawing consent immediately holds processed output while cleanup catches up.
The second control matters because cancellation is not magic. An SDK or GPU operation may already be executing when the application asks it to stop.
Define the observable contract
The UI should render the coordinator's state rather than reconstructing state from button clicks:
type ViewState = | { kind: "ready"; active: "off" } | { kind: "preparing"; requested: EffectId; resolved: EffectId } | { kind: "active"; requested: EffectId; active: EffectId; degraded: boolean } | { kind: "failed"; requested: EffectId; message: string }; requested and active are intentionally separate. If someone selects a virtual background but the device policy resolves it to a lighter beauty effect, the application must not pretend the original effect is active.
Here is the behavior we will enforce:
| Event | Immediate behavior | Eventual behavior |
|---|---|---|
| A newer effect is selected | Mark the previous preparation stale | Apply only the latest resolved effect |
| Off is selected | Hold processed output | Clear the effect and release raw video |
| Consent is withdrawn | Hold processed output immediately | Clear the effect; require a new opt-in before reuse |
| Requested effect exceeds the device budget | Show the resolved fallback | Apply the fallback or remain off |
| Preparation fails | Keep the failed request visible | Clear processing and return to raw video |
| Apply fails after preparation | Do not report the effect as active | Clear processing and return to raw video |
Holding output might appear as a frozen preview, placeholder, or temporary camera pause depending on your product. That is an application decision. The invariant is that a known-unwanted processed frame must not be treated as acceptable merely because cleanup is asynchronous.
Create the project
mkdir beauty-intent-coordinator cd beauty-intent-coordinator npm init -y npm install --save-dev typescript tsx @types/node mkdir src test Add the following implementation as src/coordinator.ts.
export type EffectId = "off" | "soft-beauty" | "virtual-bg" | "gan-look"; type EffectDefinition = { id: EffectId; needsSegmentation: boolean; needsGan: boolean; fallback: EffectId; }; const EFFECTS: Record<EffectId, EffectDefinition> = { off: { id: "off", needsSegmentation: false, needsGan: false, fallback: "off", }, "soft-beauty": { id: "soft-beauty", needsSegmentation: false, needsGan: false, fallback: "off", }, "virtual-bg": { id: "virtual-bg", needsSegmentation: true, needsGan: false, fallback: "soft-beauty", }, "gan-look": { id: "gan-look", needsSegmentation: false, needsGan: true, fallback: "soft-beauty", }, }; export type DevicePolicy = { segmentationAllowed: boolean; ganAllowed: boolean; budget: "normal" | "constrained"; }; export type ViewState = | { kind: "ready"; active: "off" } | { kind: "preparing"; requested: EffectId; resolved: EffectId } | { kind: "active"; requested: EffectId; active: EffectId; degraded: boolean; } | { kind: "failed"; requested: EffectId; message: string }; export interface BeautyAdapter { /** Fetch or prepare resources. It should honor cancellation where possible. */ prepare(effect: EffectId, signal: AbortSignal): Promise<void>; /** Commit one prepared effect to the processing pipeline. */ apply(effect: EffectId): Promise<void>; /** Remove appearance processing and return to unmodified video. */ clear(): Promise<void>; } export interface OutputGate { /** Synchronously prevent processed output from being shown or published. */ hold(reason: "off-requested" | "consent-withdrawn"): void; /** Release the unmodified camera path after processing has been cleared. */ releaseRaw(): void; } export class BeautyCoordinator { private consent = false; private desired: EffectId = "off"; private version = 0; private preparation?: AbortController; private work: Promise<void> = Promise.resolve(); private state: ViewState = { kind: "ready", active: "off" }; constructor( private readonly adapter: BeautyAdapter, private readonly gate: OutputGate, private policy: DevicePolicy, ) {} getState(): ViewState { return this.state; } setConsent(granted: boolean): void { if (granted === this.consent) return; this.consent = granted; this.version += 1; this.preparation?.abort(); if (!granted) { // Consent withdrawal invalidates the old preference. Re-enabling consent // will not silently restore a previous appearance effect. this.desired = "off"; this.gate.hold("consent-withdrawn"); } this.enqueue(); } select(effect: EffectId): void { if (effect !== "off" && !this.consent) { this.state = { kind: "failed", requested: effect, message: "Consent is required before applying an appearance effect.", }; return; } this.desired = effect; this.version += 1; this.preparation?.abort(); if (effect === "off") { this.gate.hold("off-requested"); } this.enqueue(); } updatePolicy(policy: DevicePolicy): void { this.policy = policy; this.version += 1; this.preparation?.abort(); this.enqueue(); } async idle(): Promise<void> { await this.work; } private enqueue(): void { // One writer reaches the adapter at a time. Each queued reconciliation // reads the latest intent, so bursts naturally coalesce. this.work = this.work.then(() => this.reconcile()); } private resolve(requested: EffectId): EffectId { const effect = EFFECTS[requested]; if (requested === "off") return "off"; if (this.policy.budget === "constrained") { if (effect.needsGan || effect.needsSegmentation) { return effect.fallback; } } if (effect.needsGan && !this.policy.ganAllowed) { return effect.fallback; } if (effect.needsSegmentation && !this.policy.segmentationAllowed) { return effect.fallback; } return requested; } private async reconcile(): Promise<void> { const operationVersion = this.version; const requested = this.desired; if (!this.consent || requested === "off") { try { await this.adapter.clear(); this.state = { kind: "ready", active: "off" }; this.gate.releaseRaw(); } catch (error) { this.state = { kind: "failed", requested: "off", message: `Could not clear the effect: ${messageOf(error)}`, }; // Do not release processed output when clear has not been confirmed. } return; } const resolved = this.resolve(requested); this.state = { kind: "preparing", requested, resolved }; const abort = new AbortController(); this.preparation = abort; try { await this.adapter.prepare(resolved, abort.signal); // Preparation completed, but another user action may now be authoritative. if (operationVersion !== this.version) return; await this.adapter.apply(resolved); // apply() might not be cancellable. If intent changed while it ran, leave // final reconciliation to the queued operation. if (operationVersion !== this.version) return; this.state = { kind: "active", requested, active: resolved, degraded: requested !== resolved, }; } catch (error) { if (operationVersion !== this.version || isAbort(error)) return; try { await this.adapter.clear(); this.gate.releaseRaw(); } catch { // Keep the output gate's existing safety posture if cleanup also fails. } this.state = { kind: "failed", requested, message: `Effect unavailable: ${messageOf(error)}`, }; } finally { if (this.preparation === abort) this.preparation = undefined; } } } function isAbort(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } function messageOf(error: unknown): string { return error instanceof Error ? error.message : "unknown error"; } Several choices here are deliberate:
- Selection and consent are not the same state. Device support cannot grant permission.
- Consent withdrawal forgets the old effect. Granting consent again does not silently restore it.
- Adapter calls are serialized. Two asynchronous effect commits cannot race as independent writers.
- Preparation is cancellable. Resource work can stop early where the underlying implementation supports it.
- Application is still version-checked. Cancellation alone is insufficient because commit operations may be non-cancellable.
- Cleanup must succeed before raw output is released. A failed
clear()is not equivalent to an effect being off.
Reproduce the races
Create test/coordinator.test.ts:
import assert from "node:assert/strict"; import test from "node:test"; import { BeautyAdapter, BeautyCoordinator, EffectId, OutputGate, } from "../src/coordinator.js"; const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); class FakeAdapter implements BeautyAdapter { prepared: EffectId[] = []; applied: EffectId[] = []; clears = 0; prepareDelay = 0; applyDelay = 0; failApply = false; async prepare(effect: EffectId, signal: AbortSignal): Promise<void> { this.prepared.push(effect); await new Promise<void>((resolve, reject) => { const timer = setTimeout(resolve, this.prepareDelay); signal.addEventListener( "abort", () => { clearTimeout(timer); const error = new Error("aborted"); error.name = "AbortError"; reject(error); }, { once: true }, ); }); } async apply(effect: EffectId): Promise<void> { await sleep(this.applyDelay); if (this.failApply) throw new Error("GPU setup failed"); this.applied.push(effect); } async clear(): Promise<void> { this.clears += 1; } } class FakeGate implements OutputGate { held = false; reasons: string[] = []; hold(reason: "off-requested" | "consent-withdrawn"): void { this.held = true; this.reasons.push(reason); } releaseRaw(): void { this.held = false; } } const normalPolicy = { segmentationAllowed: true, ganAllowed: true, budget: "normal" as const, }; test("latest selection wins while preparation is in flight", async () => { const adapter = new FakeAdapter(); const gate = new FakeGate(); adapter.prepareDelay = 20; const coordinator = new BeautyCoordinator(adapter, gate, normalPolicy); coordinator.setConsent(true); coordinator.select("virtual-bg"); await sleep(5); // Let virtual-bg preparation begin. coordinator.select("gan-look"); await coordinator.idle(); assert.deepEqual(adapter.applied, Source:https://dev.to/susiewang/the-filter-changed-after-the-user-tapped-off-race-proof-tencent-rtc-beauty-ar-hgmRelated posts
How I designed one workflow for 4 restaurant roles (owner, waiter, kitchen, cashier)
Node.js Edtech Shipment Fan-Out: Apache Kafka and AWS SQS Retry Boundaries
Introducing Castov: A Unified Developer Toolkit for the Browser, Terminal, and AI Agents
I built a free, open-source AI website builder that runs in your browser — no sign-up to try
SolEngine Project 🌌
