A shared camera experience at a hackathon, community event, or creator booth has an awkward failure mode: the next participant can inherit the previous participant’s makeup, GAN effect, virtual background, or live state.
That is not merely a cleanup bug. It creates a consent problem.
The difficult engineering work is not making an impressive filter appear. It is proving that:
- the camera starts from a neutral state;
- effects remain local until the participant chooses to publish;
- an expensive effect can degrade without freezing the experience;
- delayed callbacks cannot restore an old participant’s settings;
- ending a session clears visual state before the next person arrives.
This tutorial builds that control layer in TypeScript. It does not invent Tencent RTC SDK methods. Instead, it places the platform-specific Beauty AR calls behind an adapter, leaving the lifecycle testable without a camera or vendor runtime.
What Beauty AR can do—and what your application must decide
Tencent RTC Beauty AR supports real-time capabilities such as beauty filters, makeup, stickers, virtual backgrounds, avatars, gesture recognition, and image or video enhancement. The official overview is the source of truth for the available product capabilities:
A GAN-backed effect may use sophisticated computer vision, but that does not make it the product decision-maker. It can transform frames; it cannot decide whether a participant consented, whether a shared device is clean, or whether visual quality is worth thermal and frame-rate pressure.
That distinction helps name the real tension around “AI beauty”: the concern is not whether the effect looks intelligent. It is whether people remain visibly in control of how their image is processed and published.
We will therefore enforce five rules:
- Every new participant starts with a sanitization operation.
- Choosing an effect is explicit and scoped to one session ID.
- Preview and publishing are separate states.
- Automatic adaptation may only reduce visual cost, never add a stronger effect.
- A session is not considered finished until output, effects, assets, and camera state have been cleared.
The lifecycle we are building
The happy path is deliberately longer than “open camera, apply filter”:
idle
-> sanitizing old state
-> local preview
-> applying participant selection
-> ready
-> published
-> ending and clearing resources
-> idle
The important failure paths are also explicit:
GAN apply fails -> try basic profile
basic apply fails -> try effect-off profile
effect-off fails -> detach output and terminate session
late apply callback -> ignore it
cleanup fails -> lock the booth; do not admit the next participant
A locked booth is inconvenient, but it is safer than pretending the previous session was cleared.
Create the reproducible project
Use a small TypeScript project with Vitest:
mkdir reset-safe-beauty-booth
cd reset-safe-beauty-booth
npm init -y
npm install --save-dev typescript tsx vitest @types/node
mkdir src
Add these scripts to package.json:
{
"scripts": {
"test": "vitest run",
"demo": "tsx src/demo.ts"
}
}
Create src/booth.ts.
Represent product state separately from SDK state
The application recognizes three visual profiles:
-
off: camera image without an optional Beauty AR effect; -
basic: a deliberately lower-cost effect set selected by your team; -
gan: the richer effect requested by the participant.
These names are application policy, not Tencent RTC edition or API names.
export type Profile = "off" | "basic" | "gan";
export type Mode =
| "idle"
| "sanitizing"
| "preview"
| "applying"
| "ready"
| "ending"
| "locked";
export interface BoothState {
mode: Mode;
sessionId: string | null;
operationId: number;
selected: Profile;
active: Profile;
consented: boolean;
published: boolean;
message: string;
}
export const initialState: BoothState = {
mode: "idle",
sessionId: null,
operationId: 0,
selected: "off",
active: "off",
consented: false,
published: false,
message: "Ready for the next participant"
};
selected and active are intentionally different. A button press changes the selection immediately, but the UI must not claim the renderer changed until the corresponding completion event arrives.
Now define the events and effects:
export type Event =
| { type: "BEGIN"; sessionId: string }
| { type: "SANITIZED"; sessionId: string; operationId: number }
| { type: "SANITIZE_FAILED"; sessionId: string; operationId: number }
| { type: "SELECT"; profile: Profile }
| {
type: "APPLIED";
sessionId: string;
operationId: number;
profile: Profile;
}
| {
type: "APPLY_FAILED";
sessionId: string;
operationId: number;
profile: Profile;
}
| { type: "PUBLISH" }
| { type: "PERFORMANCE_PRESSURE" }
| { type: "END" }
| { type: "ENDED"; sessionId: string; operationId: number }
| { type: "END_FAILED"; sessionId: string; operationId: number };
export type Effect =
| { type: "SANITIZE"; sessionId: string; operationId: number }
| { type: "OPEN_LOCAL_PREVIEW"; sessionId: string }
| {
type: "APPLY_PROFILE";
sessionId: string;
operationId: number;
profile: Profile;
}
| { type: "SET_PUBLISHING"; enabled: boolean }
| { type: "END_SESSION"; sessionId: string; operationId: number };
export interface Transition {
state: BoothState;
effects: Effect[];
}
Each asynchronous operation carries both a session ID and an operation ID. A callback must match both before it can change state.
Implement transitions that reject stale work
Add the reducer to src/booth.ts:
function nextLowerProfile(profile: Profile): Profile | null {
if (profile === "gan") return "basic";
if (profile === "basic") return "off";
return null;
}
function matches(
state: BoothState,
event: { sessionId: string; operationId: number }
): boolean {
return (
state.sessionId === event.sessionId &&
state.operationId === event.operationId
);
}
function applyProfile(
state: BoothState,
profile: Profile,
message: string
): Transition {
if (!state.sessionId) return { state, effects: [] };
const operationId = state.operationId + 1;
return {
state: {
...state,
mode: "applying",
operationId,
selected: profile,
message
},
effects: [
{
type: "APPLY_PROFILE",
sessionId: state.sessionId,
operationId,
profile
}
]
};
}
export function reduce(state: BoothState, event: Event): Transition {
switch (event.type) {
case "BEGIN": {
if (state.mode !== "idle") return { state, effects: [] };
const operationId = state.operationId + 1;
return {
state: {
...initialState,
mode: "sanitizing",
sessionId: event.sessionId,
operationId,
message: "Preparing a clean camera session"
},
effects: [
{
type: "SANITIZE",
sessionId: event.sessionId,
operationId
}
]
};
}
case "SANITIZED": {
if (state.mode !== "sanitizing" || !matches(state, event)) {
return { state, effects: [] };
}
return {
state: {
...state,
mode: "preview",
message: "Preview is local. Choose an effect or continue without one."
},
effects: [
{ type: "OPEN_LOCAL_PREVIEW", sessionId: event.sessionId }
]
};
}
case "SANITIZE_FAILED": {
if (!matches(state, event)) return { state, effects: [] };
return {
state: {
...state,
mode: "locked",
message: "Cleanup could not be verified. Staff assistance is required."
},
effects: [{ type: "SET_PUBLISHING", enabled: false }]
};
}
case "SELECT": {
if (!state.sessionId) return { state, effects: [] };
if (state.mode !== "preview" && state.mode !== "ready") {
return { state, effects: [] };
}
return applyProfile(
{ ...state, consented: true },
event.profile,
`Applying ${event.profile} profile`
);
}
case "APPLIED": {
if (state.mode !== "applying" || !matches(state, event)) {
return { state, effects: [] };
}
if (state.selected !== event.profile) {
return { state, effects: [] };
}
return {
state: {
...state,
mode: "ready",
active: event.profile,
message: state.published
? `${event.profile} profile active on published output`
: `${event.profile} profile ready in local preview`
},
effects: []
};
}
case "APPLY_FAILED": {
if (state.mode !== "applying" || !matches(state, event)) {
return { state, effects: [] };
}
const fallback = nextLowerProfile(event.profile);
if (fallback) {
return applyProfile(
state,
fallback,
`${event.profile} was unavailable; trying ${fallback}`
);
}
const operationId = state.operationId + 1;
return {
state: {
...state,
mode: "ending",
operationId,
consented: false,
published: false,
message: "A safe visual state could not be established"
},
effects: [
{ type: "SET_PUBLISHING", enabled: false },
{
type: "END_SESSION",
sessionId: state.sessionId!,
operationId
}
]
};
}
case "PUBLISH": {
if (state.mode !== "ready" || !state.consented || state.published) {
return { state, effects: [] };
}
return {
state: {
...state,
published: true,
message: "Camera output is published"
},
effects: [{ type: "SET_PUBLISHING", enabled: true }]
};
}
case "PERFORMANCE_PRESSURE": {
if (state.mode !== "ready") return { state, effects: [] };
const fallback = nextLowerProfile(state.active);
if (!fallback) return { state, effects: [] };
return applyProfile(
state,
fallback,
`Reducing visual cost from ${state.active} to ${fallback}`
);
}
case "END": {
if (!state.sessionId || state.mode === "ending") {
return { state, effects: [] };
}
const operationId = state.operationId + 1;
return {
state: {
...state,
mode: "ending",
operationId,
consented: false,
published: false,
selected: "off",
message: "Clearing this participant's session"
},
effects: [
{ type: "SET_PUBLISHING", enabled: false },
{
type: "END_SESSION",
sessionId: state.sessionId,
operationId
}
]
};
}
case "ENDED": {
if (state.mode !== "ending" || !matches(state, event)) {
return { state, effects: [] };
}
return {
state: { ...initialState, operationId: state.operationId },
effects: []
};
}
case "END_FAILED": {
if (!matches(state, event)) return { state, effects: [] };
return {
state: {
...state,
mode: "locked",
published: false,
consented: false,
message: "Session reset failed. Do not admit another participant."
},
effects: [{ type: "SET_PUBLISHING", enabled: false }]
};
}
}
}
Notice what PERFORMANCE_PRESSURE cannot do: it cannot promote off to basic or basic to gan. Automatic adaptation is one-way. Restoring a richer effect requires a participant action after the application has recovered.
Put Tencent RTC integration behind an adapter
The official low-end device guide recommends adapting effects to device capability, selecting suitable performance modes, disabling expensive segmentation or 3D/GAN effects where necessary, and controlling resolution and frame rate:
Those are policy inputs, not universal thresholds. Measure the frame behavior and device conditions that matter to your supported environment, then dispatch PERFORMANCE_PRESSURE only after sustained evidence rather than a single slow frame.
Define an application-owned port:
import type { Profile } from "./booth.js";
export interface BeautyBoothAdapter {
sanitizePreviousSession(): Promise<void>;
openLocalPreview(): Promise<void>;
applyProfile(profile: Profile): Promise<void>;
setPublishing(enabled: boolean): Promise<void>;
endSession(): Promise<void>;
}
Your platform adapter should map these operations to the documented Tencent RTC and Beauty AR integration for the SDK and platform you use. Keep that mapping out of the reducer.
The methods need clear contracts:
| Adapter operation | Required postcondition |
|---|---|
sanitizePreviousSession |
Publishing is disabled and no participant-specific effect or asset remains active |
openLocalPreview |
Camera rendering is local and not yet published |
applyProfile |
The requested application profile is active, or the promise rejects |
setPublishing(false) |
Camera output is detached from the shared/live destination |
endSession |
Output is detached, camera resources are closed, effects are cleared, and temporary assets are released |
Do not resolve endSession() after merely requesting cleanup. Resolve it only after the adapter can establish its postconditions. Otherwise the controller may admit the next participant too early.
A small effect runner converts promise results back into events:
import type { BeautyBoothAdapter } from "./adapter.js";
import type { Effect, Event } from "./booth.js";
export async function runEffect(
effect: Effect,
adapter: BeautyBoothAdapter,
dispatch: (event: Event) => void
): Promise<void> {
switch (effect.type) {
case "SANITIZE":
try {
await adapter.sanitizePreviousSession();
dispatch({
type: "SANITIZED",
sessionId: effect.sessionId,
operationId: effect.operationId
});
} catch {
dispatch({
type: "SANITIZE_FAILED",
sessionId: effect.sessionId,
operationId: effect.operationId
});
}
return;
case "OPEN_LOCAL_PREVIEW":
await adapter.openLocalPreview();
return;
case "APPLY_PROFILE":
try {
await adapter.applyProfile(effect.profile);
dispatch({
type: "APPLIED",
sessionId: effect.sessionId,
operationId: effect.operationId,
profile: effect.profile
});
} catch {
dispatch({
type: "APPLY_FAILED",
sessionId: effect.sessionId,
operationId: effect.operationId,
profile: effect.profile
});
}
return;
case "SET_PUBLISHING":
await adapter.setPublishing(effect.enabled);
return;
case "END_SESSION":
try {
await adapter.endSession();
dispatch({
type: "ENDED",
sessionId: effect.sessionId,
operationId: effect.operationId
});
} catch {
dispatch({
type: "END_FAILED",
sessionId: effect.sessionId,
operationId: effect.operationId
});
}
}
}
In production, handle a rejected SET_PUBLISHING call too. If publishing cannot be reliably disabled, move the controller to locked and invoke an independent teardown path. Never leave that promise as an unobserved rejection.
Verify the races without a camera
Create src/booth.test.ts:
import { describe, expect, it } from "vitest";
import { initialState, reduce } from "./booth.js";
function reachPreview(sessionId = "person-a") {
const begun = reduce(initialState, { type: "BEGIN", sessionId });
return reduce(begun.state, {
type: "SANITIZED",
sessionId,
operationId: begun.state.operationId
}).state;
}
describe("shared Beauty AR booth", () => {
it("does not publish before an explicit selection", () => {
const preview = reachPreview();
const result = reduce(preview, { type: "PUBLISH" });
expect(result.state.published).toBe(false);
expect(result.effects).toEqual([]);
});
it("ignores a GAN callback after the participant changes selection", () => {
const preview = reachPreview();
const gan = reduce(preview, { type: "SELECT", profile: "gan" });
// Simulate the controller issuing a newer lower-cost selection.
const newer = reduce(
{ ...gan.state, mode: "ready", active: "gan" },
{ type: "PERFORMANCE_PRESSURE" }
);
const stale = reduce(newer.state, {
type: "APPLIED",
sessionId: "person-a",
operationId: gan.state.operationId,
profile: "gan"
});
expect(stale.state.selected).toBe("basic");
expect(stale.state.active).not.toBe("gan");
});
it("falls back from GAN to basic after an apply failure", () => {
const preview = reachPreview();
const applying = reduce(preview, { type: "SELECT", profile: "gan" });
const failed = reduce(applying.state, {
type: "APPLY_FAILED",
sessionId: "person-a",
operationId: applying.state.operationId,
profile: "gan"
});
expect(failed.state.selected).toBe("basic");
expect(failed.effects[0]).toMatchObject({
type: "APPLY_PROFILE",
profile: "basic"
});
});
it("does not become idle when teardown confirmation is stale", () => {
const preview = reachPreview();
const ending = reduce(preview, { type: "END" });
const stale = reduce(ending.state, {
type: "ENDED",
sessionId: "person-a",
operationId: ending.state.operationId - 1
});
expect(stale.state.mode).toBe("ending");
});
it("locks the booth when cleanup fails", () => {
const preview = reachPreview();
const ending = reduce(preview, { type: "END" });
const failed = reduce(ending.state, {
type: "END_FAILED",
sessionId: "person-a",
operationId: ending.state.operationId
});
expect(failed.state.mode).toBe("locked");
expect(failed.state.published).toBe(false);
});
});
Run the tests:
npm test
These tests do not prove the SDK adapter releases a camera or asset. They prove the application controller will not accept the wrong completion event. Adapter verification still needs to happen on real target devices.
Decide how performance degradation should work
Avoid classifying a device from its brand or model name alone. The useful question is whether the current effect can stay inside the experience’s measured rendering budget under realistic conditions.
A practical policy can combine:
- observed frame timing over a window;
- dropped or delayed render samples exposed by your application stack;
- thermal or memory warnings available on the target platform;
- camera resolution and frame-rate configuration;
- whether segmentation, 3D, or GAN processing is enabled.
Use a team-defined sustained threshold. A single slow frame may come from startup, asset loading, or an unrelated task.
| Condition | Application decision |
|---|---|
| GAN is stable on the target device | Keep the participant’s selected profile |
| Sustained pressure while GAN is active | Move to the reviewed basic profile and explain the change |
| Sustained pressure while basic is active | Turn optional effects off |
| Pressure continues with effects off | Reduce camera workload according to your supported configuration, or stop publishing |
| Telemetry is unavailable | Start conservatively rather than assuming high capability |
There is a trade-off here. Automatic degradation preserves continuity, but changing someone’s appearance without explanation can feel like a malfunction. Pair every downgrade with visible status such as:
The selected effect was reduced to keep the camera responsive. You can retry it from preview.
Do not silently restore the richer profile later. That would make the system, rather than the participant, responsible for adding image processing.
Failure modes worth rehearsing on real devices
A participant leaves while GAN assets are loading
Dispatch END immediately. The operation ID changes, so the eventual asset callback cannot reactivate the effect. The adapter must still cancel or release the underlying load where its documented integration permits.
Cleanup reports success but a virtual background remains cached
This is an adapter contract failure. Add an integration test that starts participant B after participant A used every supported asset category. Check both visible output and the adapter’s active-resource state before enabling B’s preview.
Caching an asset for performance may be acceptable, but cached and active are different states. Participant-specific content must not remain selected.
Publishing starts before the effect is ready
The reducer permits PUBLISH only in ready. Keep the publish button disabled while mode is sanitizing, preview, applying, ending, or locked.
The camera permission dialog is denied
Treat permission denial as a preview failure, not as consent withdrawal and not as an effect failure. Show a retry path or allow the participant to leave. Do not repeatedly trigger the permission prompt.
Performance telemetry disappears
Do not invent a high-performance classification. Choose a conservative initial profile or leave the participant on off until capability is known. This is especially important for shared devices whose temperature changes throughout an event.
Staff reload the page during teardown
Persist only the minimum recovery marker needed to know that the previous shutdown was unverified. On startup, run sanitization before opening preview. Never persist a participant’s selected appearance as the default for the next session.
The interface should make control visible
The state machine only helps if the UI reflects it accurately.
A useful shared-booth interface has:
- a clear “Start my session” action;
- a local-preview label before publication;
- separate choices for no effect, basic effects, and richer effects;
- an explanation when image processing is enabled;
- a separate “Go live” or “Take photo” action;
- an always-available “End and clear my session” control;
- a locked maintenance screen when cleanup cannot be verified.
Avoid presenting gan or basic as quality judgments about the participant. They are rendering-cost profiles. Product copy should describe what processing is applied, not imply that one appearance is better.
Device verification checklist
Before opening the experience to a community, verify it on the actual device classes you intend to support:
- [ ] A new session cannot begin until startup sanitization succeeds.
- [ ] Preview is not published before the participant’s explicit action.
- [ ] Selecting “off” is a first-class choice, not a hidden fallback.
- [ ] A delayed effect callback cannot modify a newer selection.
- [ ] Ending during asset loading leaves no active effect.
- [ ] GAN failure visibly falls back to the reviewed basic profile.
- [ ] Basic failure visibly falls back to effect-off.
- [ ] Failure to establish effect-off detaches output and ends the session.
- [ ] Sustained frame pressure causes only downward adaptation.
- [ ] Resolution and frame-rate choices are tested on intended low-end devices.
- [ ] Participant B never inherits participant A’s effect or virtual background.
- [ ] A cleanup failure locks the workflow rather than showing a false success screen.
- [ ] Reloading after an interrupted teardown performs sanitization first.
The durable engineering skill here is not memorizing one GAN integration. It is defining ownership around asynchronous visual state. Once preview, consent, publishing, performance adaptation, and teardown have explicit transitions, the impressive part of the demo no longer has to carry the trust model by itself.
Disclosure: I wrote this article as part of my work with Tencent RTC. The official Tencent RTC Beauty AR overview and low-end device optimization documentation were used as implementation references.
Top comments (0)