# Hot-Swap a 3D Avatar Without Publishing the Broken Frame

> Source: <https://dev.to/susiewang/hot-swap-a-3d-avatar-without-publishing-the-broken-frame-5dgf>
> Published: 2026-09-20 02:12:32+00:00

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:

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](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.

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:

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.

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

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:

``` js
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](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.”

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.

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

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.

Create `src/avatar-loader.test.ts`:

``` js
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.

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:

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.

Unit tests should be followed by controlled failure drills.

Remove a required asset file or provide an invalid revision.

Expected result:

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.

Select avatars A, B, and C while A is still loading.

Also test this with deliberately delayed downloads rather than relying on fast local assets.

Cover the camera, move outside the supported pose, or otherwise reproduce the tracking-loss behavior relevant to your product.

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.

Force the application into its low-device tier.

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.

Inject a failure into the adapter's activation boundary.

`restore(previous)` runs.
Let activation succeed, then make disposal of the previous asset fail.

This distinguishes a presentation failure from a resource-lifecycle defect.

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.

AI can be genuinely useful for:

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.

Before enabling 3D avatar updates in a live Beauty AR session, verify that:

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.
