An AI tool can help generate a 3D avatar concept, asset manifest, or integration scaffold in minutes. The uncomfortable part begins afterward: who decides that the candidate is safe to show in a live session?
A model can produce something visually persuasive without proving that it loads on the target device, tracks correctly, stays within your rendering budget, or survives a failed wardrobe change.
That does not make AI-assisted creation useless. It changes the engineering task. Instead of treating “the asset exists” as completion, we need a controlled transition from a candidate avatar to the avatar participants actually see.
In this tutorial, we will build that transition as a two-phase loader:
- Prepare the candidate without making it visible.
- Validate its manifest against an application-owned policy.
- Qualify several rendered samples.
- Commit it synchronously.
- Preserve or restore the last known-good presentation on failure.
Tencent RTC Beauty AR supports scenarios including avatars, beauty effects, stickers, virtual backgrounds, and image or video enhancement. The official overview is here: https://trtc.io/document/beauty-ar-overview
The controller below deliberately sits around the selected Beauty AR SDK integration. It does not invent SDK methods. You map its narrow adapter to the APIs and platform described by the official documentation.
The invariant: a candidate is not live state
Suppose a user is already represented by avatar-blue-v4 and selects a newly generated 3D avatar.
The tempting implementation is:
await loadAvatar(nextAsset);
showAvatar(nextAsset);
That leaves several questions unanswered:
- What remains visible while loading?
- What if the asset loads but produces invalid frames?
- What if another selection arrives before loading finishes?
- What if activation partially changes the renderer and then throws?
- What happens on a device where 3D effects are outside the accepted budget?
Our invariant is stronger:
The committed avatar remains visible until a newer candidate has passed policy validation and runtime qualification.
If no avatar has been committed, the application should show an explicit placeholder or paused-avatar state. It should not silently reveal the camera; camera presentation requires its own product policy and consent decision.
Create the TypeScript project
mkdir transactional-avatar-loader
cd transactional-avatar-loader
npm init -y
npm install --save-dev typescript vitest @types/node
npx tsc --init
mkdir src
Add the test script to package.json:
{
"scripts": {
"test": "vitest run"
}
}
The implementation has no rendering-library dependency. That makes its state and failure behavior testable without a camera, GPU, or live room.
Describe assets separately from loaded resources
Create src/avatar-loader.ts:
export type DeviceTier = "low" | "mid" | "high";
export interface AvatarManifest {
id: string;
revision: string;
format: "3d-avatar";
declaredBytes: number;
}
export interface PreparedAvatar {
assetId: string;
revision: string;
opaqueHandle: unknown;
}
export interface FrameProbe {
rendered: boolean;
tracking: "good" | "lost";
frameTimeMs: number;
}
export interface TierPolicy {
allow3dAvatar: boolean;
maxDeclaredBytes: number;
qualificationFrames: number;
maxBadFrames: number;
maxFrameTimeMs: number;
}
export type PolicyByTier = Record<DeviceTier, TierPolicy>;
These thresholds belong to the application, not to Tencent RTC and not to an AI asset generator. They should be selected from measurements on devices your product supports.
A starter configuration might look like this:
export const examplePolicy: PolicyByTier = {
low: {
allow3dAvatar: false,
maxDeclaredBytes: 0,
qualificationFrames: 0,
maxBadFrames: 0,
maxFrameTimeMs: 0
},
mid: {
allow3dAvatar: true,
maxDeclaredBytes: 8_000_000,
qualificationFrames: 12,
maxBadFrames: 2,
maxFrameTimeMs: 40
},
high: {
allow3dAvatar: true,
maxDeclaredBytes: 16_000_000,
qualificationFrames: 12,
maxBadFrames: 1,
maxFrameTimeMs: 32
}
};
Those numbers are illustrative acceptance values, not product benchmarks or universal device limits. Replace them with thresholds derived from your own device matrix.
Tencent RTC's low-end device optimization guide recommends adapting effects by device tier and avoiding expensive capabilities such as 3D, GAN effects, or segmentation when the device cannot sustain them. It also discusses controlling resolution, frame rate, and performance modes: https://trtc.io/document/66968
The important product decision is that allow3dAvatar: false means “do not attempt this workload,” not “try it and hope users tolerate the result.”
Put the renderer behind a transactional port
Continue in the same file:
export interface AvatarPort {
prepare(manifest: AvatarManifest): Promise<PreparedAvatar>;
probe(candidate: PreparedAvatar): Promise<FrameProbe>;
// Keep this operation synchronous at the controller boundary.
activate(candidate: PreparedAvatar): void;
restore(previous: PreparedAvatar | null): void;
dispose(candidate: PreparedAvatar): Promise<void>;
}
prepare can download, decode, and initialize resources in a hidden or off-screen context. probe asks the integration for application-level observations about candidate output.
The activate boundary is intentionally synchronous. JavaScript cannot interleave another selection in the middle of a synchronous commit, so an old asynchronous completion cannot win after the final version check.
If the platform-specific activation API is asynchronous, the adapter must provide equivalent serialization or transaction semantics. Do not simply change this method to return a promise without reconsidering the race: a newer user selection could arrive while the older candidate is being made visible.
Make every visible transition explicit
export type LoaderState =
| { tag: "showing"; avatarId: string | null; warning?: string }
| { tag: "preparing"; candidateId: string; stillShowing: string | null }
| { tag: "qualifying"; candidateId: string; stillShowing: string | null }
| { tag: "committing"; candidateId: string; stillShowing: string | null }
| {
tag: "failed";
candidateId: string;
stillShowing: string | null;
reason: string;
};
export type SwitchResult =
| { ok: true; avatarId: string }
| { ok: false; reason: string; superseded?: boolean };
This state is useful beyond the renderer:
- The UI can distinguish downloading from qualification.
- Accessibility text can say that the existing avatar is still active.
- Logs can identify which phase failed.
- A retry button can target the failed candidate rather than reloading the page.
Now implement the controller:
export class AvatarLoader {
private operation = 0;
private current: PreparedAvatar | null = null;
public state: LoaderState = {
tag: "showing",
avatarId: null
};
constructor(
private readonly port: AvatarPort,
private readonly policies: PolicyByTier
) {}
async switchTo(
manifest: AvatarManifest,
tier: DeviceTier
): Promise<SwitchResult> {
const op = ++this.operation;
const policy = this.policies[tier];
const visibleId = this.current?.assetId ?? null;
const policyFailure = this.validateManifest(manifest, policy);
if (policyFailure) {
this.failIfCurrent(op, manifest.id, policyFailure);
return { ok: false, reason: policyFailure };
}
this.state = {
tag: "preparing",
candidateId: manifest.id,
stillShowing: visibleId
};
let candidate: PreparedAvatar;
try {
candidate = await this.port.prepare(manifest);
} catch (error) {
const reason = `Preparation failed: ${messageOf(error)}`;
this.failIfCurrent(op, manifest.id, reason);
return { ok: false, reason };
}
if (!this.isCurrent(op)) {
await this.safeDispose(candidate);
return {
ok: false,
reason: "Superseded by a newer selection",
superseded: true
};
}
this.state = {
tag: "qualifying",
candidateId: manifest.id,
stillShowing: visibleId
};
let badFrames = 0;
for (let index = 0; index < policy.qualificationFrames; index++) {
let sample: FrameProbe;
try {
sample = await this.port.probe(candidate);
} catch (error) {
await this.safeDispose(candidate);
const reason = `Qualification probe failed: ${messageOf(error)}`;
this.failIfCurrent(op, manifest.id, reason);
return { ok: false, reason };
}
if (!this.isCurrent(op)) {
await this.safeDispose(candidate);
return {
ok: false,
reason: "Superseded during qualification",
superseded: true
};
}
const acceptable =
sample.rendered &&
sample.tracking === "good" &&
sample.frameTimeMs <= policy.maxFrameTimeMs;
if (!acceptable) badFrames++;
if (badFrames > policy.maxBadFrames) {
await this.safeDispose(candidate);
const reason = "Candidate exceeded the qualification budget";
this.failIfCurrent(op, manifest.id, reason);
return { ok: false, reason };
}
}
if (!this.isCurrent(op)) {
await this.safeDispose(candidate);
return {
ok: false,
reason: "Superseded before commit",
superseded: true
};
}
this.state = {
tag: "committing",
candidateId: manifest.id,
stillShowing: visibleId
};
const previous = this.current;
try {
this.port.activate(candidate);
this.current = candidate;
this.state = { tag: "showing", avatarId: candidate.assetId };
} catch (error) {
try {
this.port.restore(previous);
} catch (restoreError) {
const reason =
`Activation failed (${messageOf(error)}); ` +
`restore also failed (${messageOf(restoreError)})`;
await this.safeDispose(candidate);
this.failIfCurrent(op, manifest.id, reason);
return { ok: false, reason };
}
await this.safeDispose(candidate);
const reason = `Activation failed: ${messageOf(error)}`;
this.failIfCurrent(op, manifest.id, reason);
return { ok: false, reason };
}
if (previous) {
try {
await this.port.dispose(previous);
} catch (error) {
this.state = {
tag: "showing",
avatarId: candidate.assetId,
warning: `Old avatar cleanup failed: ${messageOf(error)}`
};
}
}
return { ok: true, avatarId: candidate.assetId };
}
private validateManifest(
manifest: AvatarManifest,
policy: TierPolicy
): string | null {
if (!policy.allow3dAvatar) {
return "3D avatars are disabled for this device tier";
}
if (!manifest.id || !manifest.revision) {
return "Manifest identity or revision is missing";
}
if (!Number.isSafeInteger(manifest.declaredBytes)) {
return "Declared asset size is invalid";
}
if (manifest.declaredBytes <= 0) {
return "Declared asset size must be positive";
}
if (manifest.declaredBytes > policy.maxDeclaredBytes) {
return "Asset exceeds this tier's declared-size budget";
}
return null;
}
private isCurrent(op: number): boolean {
return op === this.operation;
}
private failIfCurrent(
op: number,
candidateId: string,
reason: string
): void {
if (!this.isCurrent(op)) return;
this.state = {
tag: "failed",
candidateId,
stillShowing: this.current?.assetId ?? null,
reason
};
}
private async safeDispose(candidate: PreparedAvatar): Promise<void> {
try {
await this.port.dispose(candidate);
} catch {
// Record this through application telemetry in a real integration.
}
}
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
There are two details worth noticing.
First, loading success does not imply presentation success. The candidate must produce enough acceptable probes before activation.
Second, cleanup failure does not roll back a successful commit. At that point the new avatar is already the visible truth. Cleanup becomes a resource warning to observe and remediate, not a reason to lie to the UI about what is showing.
Verify the transitions with a fake renderer
Create src/avatar-loader.test.ts:
import { describe, expect, it } from "vitest";
import {
AvatarLoader,
AvatarManifest,
AvatarPort,
PreparedAvatar,
examplePolicy
} from "./avatar-loader";
class FakePort implements AvatarPort {
active: PreparedAvatar | null = null;
disposed: string[] = [];
activationShouldFail = false;
async prepare(manifest: AvatarManifest): Promise<PreparedAvatar> {
return {
assetId: manifest.id,
revision: manifest.revision,
opaqueHandle: {}
};
}
async probe() {
return {
rendered: true,
tracking: "good" as const,
frameTimeMs: 20
};
}
activate(candidate: PreparedAvatar): void {
if (this.activationShouldFail) {
throw new Error("renderer rejected commit");
}
this.active = candidate;
}
restore(previous: PreparedAvatar | null): void {
this.active = previous;
}
async dispose(candidate: PreparedAvatar): Promise<void> {
this.disposed.push(candidate.assetId);
}
}
const asset = (id: string): AvatarManifest => ({
id,
revision: "1",
format: "3d-avatar",
declaredBytes: 1_000_000
});
describe("AvatarLoader", () => {
it("commits a candidate only after qualification", async () => {
const port = new FakePort();
const loader = new AvatarLoader(port, examplePolicy);
const result = await loader.switchTo(asset("avatar-green"), "high");
expect(result).toEqual({ ok: true, avatarId: "avatar-green" });
expect(port.active?.assetId).toBe("avatar-green");
expect(loader.state).toEqual({
tag: "showing",
avatarId: "avatar-green"
});
});
it("restores the previous avatar when activation fails", async () => {
const port = new FakePort();
const loader = new AvatarLoader(port, examplePolicy);
await loader.switchTo(asset("known-good"), "high");
port.activationShouldFail = true;
const result = await loader.switchTo(asset("candidate"), "high");
expect(result.ok).toBe(false);
expect(port.active?.assetId).toBe("known-good");
expect(loader.state).toMatchObject({
tag: "failed",
candidateId: "candidate",
stillShowing: "known-good"
});
});
it("does not attempt 3D activation on a disallowed tier", async () => {
const port = new FakePort();
const loader = new AvatarLoader(port, examplePolicy);
const result = await loader.switchTo(asset("heavy-avatar"), "low");
expect(result).toEqual({
ok: false,
reason: "3D avatars are disabled for this device tier"
});
expect(port.active).toBeNull();
});
});
Run the suite:
npm test
These tests prove control-flow invariants. They do not prove that your real avatar renders correctly. That requires integration tests on target hardware.
Map the port to Tencent RTC Beauty AR
Keep the platform adapter narrow:
class TencentBeautyAvatarAdapter implements AvatarPort {
async prepare(manifest: AvatarManifest): Promise<PreparedAvatar> {
// Load and initialize the avatar using the supported Beauty AR
// integration for your selected platform.
// Do not attach it to the visible output yet.
throw new Error("Map to the documented platform integration");
}
async probe(candidate: PreparedAvatar): Promise<FrameProbe> {
// Return observations collected by your application instrumentation.
throw new Error("Implement application-level frame observations");
}
activate(candidate: PreparedAvatar): void {
// Atomically attach the prepared candidate to visible output.
throw new Error("Implement documented activation behavior");
}
restore(previous: PreparedAvatar | null): void {
// Restore the previous avatar or the explicit avatar-paused view.
throw new Error("Implement rollback behavior");
}
async dispose(candidate: PreparedAvatar): Promise<void> {
// Release candidate-specific resources.
throw new Error("Implement documented cleanup behavior");
}
}
This skeleton is intentionally not filled with guessed API names. Use the SDK and platform instructions linked from the official Beauty AR documentation.
The adapter contract gives that integration a testable meaning:
- Prepare may allocate resources but may not change visible presentation.
- Probe observes the candidate, not the currently committed avatar.
- Activate changes presentation at one controlled boundary.
- Restore has enough information to recover from partial activation.
- Dispose cannot change whichever avatar is currently committed.
If your integration cannot satisfy those rules, change the user experience accordingly. For example, introduce an explicit “avatar updating” scene rather than claiming that hot-swapping is atomic.
Failure drills to run on real devices
Unit tests should be followed by controlled failure drills.
1. Corrupt or incomplete generated asset
Remove a required asset file or provide an invalid revision.
Expected result:
- Preparation fails.
- The previous avatar remains visible.
- The UI identifies the candidate as failed.
- Retry does not require restarting the call.
A manifest size check is not an integrity check. In production, validate the actual downloaded bytes and any integrity metadata supplied by your asset pipeline.
2. Rapid selection changes
Select avatars A, B, and C while A is still loading.
Expected result:
- Only the latest operation may reach commit.
- Superseded resources are disposed.
- An old completion cannot change the current UI state.
Also test this with deliberately delayed downloads rather than relying on fast local assets.
3. Tracking loss during qualification
Cover the camera, move outside the supported pose, or otherwise reproduce the tracking-loss behavior relevant to your product.
Expected result:
- The candidate accumulates bad probes.
- It is rejected after crossing the configured budget.
- The current avatar remains active.
Do not use a single good frame as proof of readiness. Conversely, do not choose an arbitrary qualification window and call it universal. Measure how your application behaves across representative devices and user movement.
4. Device-tier rejection
Force the application into its low-device tier.
Expected result:
- The 3D workload is not initialized.
- The user sees the documented lightweight option or avatar-paused state.
- The UI does not describe the policy decision as a mysterious renderer failure.
Possible lower-cost experiences include a basic supported effect or no avatar effect. The exact ladder depends on the capabilities documented for your selected integration and on what the user has consented to show.
5. Activation failure after successful qualification
Inject a failure into the adapter's activation boundary.
Expected result:
-
restore(previous)runs. - The last known-good avatar remains the visible truth.
- The candidate is disposed.
- The failure is observable by phase and asset revision.
6. Cleanup failure
Let activation succeed, then make disposal of the previous asset fail.
Expected result:
- The new avatar remains committed.
- The application emits a cleanup warning.
- Resource monitoring can detect repeated cleanup failures.
This distinguishes a presentation failure from a resource-lifecycle defect.
Decide what “qualified” means for your product
A useful qualification policy combines several signals rather than hiding everything under “FPS looks okay.”
| Signal | What it catches | Limitation |
|---|---|---|
| Candidate rendered | Missing or invalid output | Does not prove tracking quality |
| Tracking state | Frozen or detached avatar behavior | Can vary with pose and environment |
| Frame time | Expensive candidate rendering | Needs device-specific thresholds |
| Asset revision | Stale or mismatched content | Does not validate runtime behavior |
| Cleanup result | Resource lifecycle defects | Happens after the visible decision |
For actual device-tier decisions, profile the full session workload: camera capture, Beauty AR processing, rendering, and RTC media behavior. A candidate that passes in an isolated asset viewer has not proved that it fits inside a live-call budget.
Where AI helps—and where the human decision remains
AI can be genuinely useful for:
- proposing avatar variations;
- producing draft asset metadata;
- generating adapter scaffolding;
- suggesting test cases;
- classifying build or validation errors for an operator.
It has not demonstrated production readiness merely by producing an asset that opens once.
The durable engineering skill here is not hand-authoring every polygon. It is defining the boundaries the generated work must pass: identity, integrity, device policy, runtime qualification, commit semantics, rollback, and observable cleanup.
That is also a healthier way to frame the anxiety around AI-assisted development. You do not have to compete with a generator at producing the first plausible artifact. Your responsibility is to decide what evidence makes that artifact trustworthy in a live system.
Release checklist
Before enabling 3D avatar updates in a live Beauty AR session, verify that:
- [ ] Candidate assets are prepared without changing visible output.
- [ ] The manifest is revisioned and validated before loading.
- [ ] Device tier comes from measurements, not only a user-agent string.
- [ ] 3D processing is skipped when the selected tier disallows it.
- [ ] Qualification observes rendering, tracking, and frame behavior.
- [ ] Thresholds are documented as application policy, not universal claims.
- [ ] A newer selection invalidates older asynchronous work.
- [ ] Activation has transaction or serialization semantics.
- [ ] Activation failure restores the last known-good presentation.
- [ ] The no-avatar fallback is explicit and consent-safe.
- [ ] Candidate and previous resources are disposed independently.
- [ ] Tests cover corruption, supersession, tracking loss, activation failure, and cleanup failure.
- [ ] The complete flow has been exercised on representative target devices.
The useful question for an avatar feature is not merely, “Can we generate it?” It is, “What must be true before we let it replace the presentation already working?”
Relationship disclosure: I have a content relationship with Tencent RTC, and I used the official Tencent RTC Beauty AR documentation as the implementation reference for this article.
Top comments (1)
The two-phase loader with "preserve last known-good on failure" is the same shape as an atomic deploy, and it's underrated how many domains need it.
We rewrote our static publish path for exactly this reason last week, and the failure mode matches yours precisely: the old code deleted the previous build and then uploaded the new one, so during the upload a visitor got either a 404 or an index.html referencing assets that weren't there yet. About 12 seconds of broken frames per publish on a 250-file site.
The fix was the same four steps you describe - write the candidate alongside the old one, commit the entry point last (for us HTML after assets; for you the avatar after the manifest qualifies), and on failure leave the previous version completely untouched.
The one piece I'd add to your list, because it bit us: cleanup needs a delay. We prune the files the new build didn't write, but only after three minutes, which is longer than our edge cache holds the old HTML. Delete immediately and you break the visitors still being served the previous entry point from cache. "Commit synchronously, clean up later" is the rule.
Measured before and after: 55 of 91 visits broken during a republish, down to 0 across roughly 3,500.