{"slug": "hot-swap-a-3d-avatar-without-publishing-the-broken-frame", "title": "Hot-Swap a 3D Avatar Without Publishing the Broken Frame", "summary": "A developer has published a tutorial for building a two-phase, transactional avatar loader that keeps the currently committed 3D avatar visible until a newly generated candidate passes policy validation and runtime qualification. The controller wraps Tencent RTC's Beauty AR SDK through a narrow adapter, using per-device-tier thresholds for declared asset size, qualification frames, bad-frame tolerance, and frame time, and deliberately avoids revealing the camera when no avatar has been committed. The implementation has no rendering-library dependency, making its state and failure behavior testable without a camera, GPU, or live room.", "body_md": "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?**\n\nA 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.\n\nThat 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**.\n\nIn this tutorial, we will build that transition as a two-phase loader:\n\nTencent 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)\n\nThe 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.\n\nSuppose a user is already represented by `avatar-blue-v4` and selects a newly generated 3D avatar.\n\nThe tempting implementation is:\n\n```\nawait loadAvatar(nextAsset);\nshowAvatar(nextAsset);\n```\n\nThat leaves several questions unanswered:\n\nOur invariant is stronger:\n\nThe committed avatar remains visible until a newer candidate has passed policy validation and runtime qualification.\n\nIf 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.\n\n```\nmkdir transactional-avatar-loader\ncd transactional-avatar-loader\nnpm init -y\nnpm install --save-dev typescript vitest @types/node\nnpx tsc --init\nmkdir src\n```\n\nAdd the test script to `package.json`:\n\n```\n{\n  \"scripts\": {\n    \"test\": \"vitest run\"\n  }\n}\n```\n\nThe implementation has no rendering-library dependency. That makes its state and failure behavior testable without a camera, GPU, or live room.\n\nCreate `src/avatar-loader.ts`:\n\n```\nexport type DeviceTier = \"low\" | \"mid\" | \"high\";\n\nexport interface AvatarManifest {\n  id: string;\n  revision: string;\n  format: \"3d-avatar\";\n  declaredBytes: number;\n}\n\nexport interface PreparedAvatar {\n  assetId: string;\n  revision: string;\n  opaqueHandle: unknown;\n}\n\nexport interface FrameProbe {\n  rendered: boolean;\n  tracking: \"good\" | \"lost\";\n  frameTimeMs: number;\n}\n\nexport interface TierPolicy {\n  allow3dAvatar: boolean;\n  maxDeclaredBytes: number;\n  qualificationFrames: number;\n  maxBadFrames: number;\n  maxFrameTimeMs: number;\n}\n\nexport type PolicyByTier = Record<DeviceTier, TierPolicy>;\n```\n\nThese 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.\n\nA starter configuration might look like this:\n\n``` js\nexport const examplePolicy: PolicyByTier = {\n  low: {\n    allow3dAvatar: false,\n    maxDeclaredBytes: 0,\n    qualificationFrames: 0,\n    maxBadFrames: 0,\n    maxFrameTimeMs: 0\n  },\n  mid: {\n    allow3dAvatar: true,\n    maxDeclaredBytes: 8_000_000,\n    qualificationFrames: 12,\n    maxBadFrames: 2,\n    maxFrameTimeMs: 40\n  },\n  high: {\n    allow3dAvatar: true,\n    maxDeclaredBytes: 16_000_000,\n    qualificationFrames: 12,\n    maxBadFrames: 1,\n    maxFrameTimeMs: 32\n  }\n};\n```\n\nThose numbers are illustrative acceptance values, **not product benchmarks or universal device limits**. Replace them with thresholds derived from your own device matrix.\n\nTencent 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)\n\nThe important product decision is that `allow3dAvatar: false` means “do not attempt this workload,” not “try it and hope users tolerate the result.”\n\nContinue in the same file:\n\n```\nexport interface AvatarPort {\n  prepare(manifest: AvatarManifest): Promise<PreparedAvatar>;\n  probe(candidate: PreparedAvatar): Promise<FrameProbe>;\n\n  // Keep this operation synchronous at the controller boundary.\n  activate(candidate: PreparedAvatar): void;\n  restore(previous: PreparedAvatar | null): void;\n\n  dispose(candidate: PreparedAvatar): Promise<void>;\n}\n```\n\n`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.\n\nThe `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.\n\nIf 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.\n\n```\nexport type LoaderState =\n  | { tag: \"showing\"; avatarId: string | null; warning?: string }\n  | { tag: \"preparing\"; candidateId: string; stillShowing: string | null }\n  | { tag: \"qualifying\"; candidateId: string; stillShowing: string | null }\n  | { tag: \"committing\"; candidateId: string; stillShowing: string | null }\n  | {\n      tag: \"failed\";\n      candidateId: string;\n      stillShowing: string | null;\n      reason: string;\n    };\n\nexport type SwitchResult =\n  | { ok: true; avatarId: string }\n  | { ok: false; reason: string; superseded?: boolean };\n```\n\nThis state is useful beyond the renderer:\n\nNow implement the controller:\n\n```\nexport class AvatarLoader {\n  private operation = 0;\n  private current: PreparedAvatar | null = null;\n\n  public state: LoaderState = {\n    tag: \"showing\",\n    avatarId: null\n  };\n\n  constructor(\n    private readonly port: AvatarPort,\n    private readonly policies: PolicyByTier\n  ) {}\n\n  async switchTo(\n    manifest: AvatarManifest,\n    tier: DeviceTier\n  ): Promise<SwitchResult> {\n    const op = ++this.operation;\n    const policy = this.policies[tier];\n    const visibleId = this.current?.assetId ?? null;\n\n    const policyFailure = this.validateManifest(manifest, policy);\n    if (policyFailure) {\n      this.failIfCurrent(op, manifest.id, policyFailure);\n      return { ok: false, reason: policyFailure };\n    }\n\n    this.state = {\n      tag: \"preparing\",\n      candidateId: manifest.id,\n      stillShowing: visibleId\n    };\n\n    let candidate: PreparedAvatar;\n\n    try {\n      candidate = await this.port.prepare(manifest);\n    } catch (error) {\n      const reason = `Preparation failed: ${messageOf(error)}`;\n      this.failIfCurrent(op, manifest.id, reason);\n      return { ok: false, reason };\n    }\n\n    if (!this.isCurrent(op)) {\n      await this.safeDispose(candidate);\n      return {\n        ok: false,\n        reason: \"Superseded by a newer selection\",\n        superseded: true\n      };\n    }\n\n    this.state = {\n      tag: \"qualifying\",\n      candidateId: manifest.id,\n      stillShowing: visibleId\n    };\n\n    let badFrames = 0;\n\n    for (let index = 0; index < policy.qualificationFrames; index++) {\n      let sample: FrameProbe;\n\n      try {\n        sample = await this.port.probe(candidate);\n      } catch (error) {\n        await this.safeDispose(candidate);\n        const reason = `Qualification probe failed: ${messageOf(error)}`;\n        this.failIfCurrent(op, manifest.id, reason);\n        return { ok: false, reason };\n      }\n\n      if (!this.isCurrent(op)) {\n        await this.safeDispose(candidate);\n        return {\n          ok: false,\n          reason: \"Superseded during qualification\",\n          superseded: true\n        };\n      }\n\n      const acceptable =\n        sample.rendered &&\n        sample.tracking === \"good\" &&\n        sample.frameTimeMs <= policy.maxFrameTimeMs;\n\n      if (!acceptable) badFrames++;\n\n      if (badFrames > policy.maxBadFrames) {\n        await this.safeDispose(candidate);\n        const reason = \"Candidate exceeded the qualification budget\";\n        this.failIfCurrent(op, manifest.id, reason);\n        return { ok: false, reason };\n      }\n    }\n\n    if (!this.isCurrent(op)) {\n      await this.safeDispose(candidate);\n      return {\n        ok: false,\n        reason: \"Superseded before commit\",\n        superseded: true\n      };\n    }\n\n    this.state = {\n      tag: \"committing\",\n      candidateId: manifest.id,\n      stillShowing: visibleId\n    };\n\n    const previous = this.current;\n\n    try {\n      this.port.activate(candidate);\n      this.current = candidate;\n      this.state = { tag: \"showing\", avatarId: candidate.assetId };\n    } catch (error) {\n      try {\n        this.port.restore(previous);\n      } catch (restoreError) {\n        const reason =\n          `Activation failed (${messageOf(error)}); ` +\n          `restore also failed (${messageOf(restoreError)})`;\n\n        await this.safeDispose(candidate);\n        this.failIfCurrent(op, manifest.id, reason);\n        return { ok: false, reason };\n      }\n\n      await this.safeDispose(candidate);\n      const reason = `Activation failed: ${messageOf(error)}`;\n      this.failIfCurrent(op, manifest.id, reason);\n      return { ok: false, reason };\n    }\n\n    if (previous) {\n      try {\n        await this.port.dispose(previous);\n      } catch (error) {\n        this.state = {\n          tag: \"showing\",\n          avatarId: candidate.assetId,\n          warning: `Old avatar cleanup failed: ${messageOf(error)}`\n        };\n      }\n    }\n\n    return { ok: true, avatarId: candidate.assetId };\n  }\n\n  private validateManifest(\n    manifest: AvatarManifest,\n    policy: TierPolicy\n  ): string | null {\n    if (!policy.allow3dAvatar) {\n      return \"3D avatars are disabled for this device tier\";\n    }\n\n    if (!manifest.id || !manifest.revision) {\n      return \"Manifest identity or revision is missing\";\n    }\n\n    if (!Number.isSafeInteger(manifest.declaredBytes)) {\n      return \"Declared asset size is invalid\";\n    }\n\n    if (manifest.declaredBytes <= 0) {\n      return \"Declared asset size must be positive\";\n    }\n\n    if (manifest.declaredBytes > policy.maxDeclaredBytes) {\n      return \"Asset exceeds this tier's declared-size budget\";\n    }\n\n    return null;\n  }\n\n  private isCurrent(op: number): boolean {\n    return op === this.operation;\n  }\n\n  private failIfCurrent(\n    op: number,\n    candidateId: string,\n    reason: string\n  ): void {\n    if (!this.isCurrent(op)) return;\n\n    this.state = {\n      tag: \"failed\",\n      candidateId,\n      stillShowing: this.current?.assetId ?? null,\n      reason\n    };\n  }\n\n  private async safeDispose(candidate: PreparedAvatar): Promise<void> {\n    try {\n      await this.port.dispose(candidate);\n    } catch {\n      // Record this through application telemetry in a real integration.\n    }\n  }\n}\n\nfunction messageOf(error: unknown): string {\n  return error instanceof Error ? error.message : String(error);\n}\n```\n\nThere are two details worth noticing.\n\nFirst, loading success does not imply presentation success. The candidate must produce enough acceptable probes before activation.\n\nSecond, 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.\n\nCreate `src/avatar-loader.test.ts`:\n\n``` js\nimport { describe, expect, it } from \"vitest\";\nimport {\n  AvatarLoader,\n  AvatarManifest,\n  AvatarPort,\n  PreparedAvatar,\n  examplePolicy\n} from \"./avatar-loader\";\n\nclass FakePort implements AvatarPort {\n  active: PreparedAvatar | null = null;\n  disposed: string[] = [];\n  activationShouldFail = false;\n\n  async prepare(manifest: AvatarManifest): Promise<PreparedAvatar> {\n    return {\n      assetId: manifest.id,\n      revision: manifest.revision,\n      opaqueHandle: {}\n    };\n  }\n\n  async probe() {\n    return {\n      rendered: true,\n      tracking: \"good\" as const,\n      frameTimeMs: 20\n    };\n  }\n\n  activate(candidate: PreparedAvatar): void {\n    if (this.activationShouldFail) {\n      throw new Error(\"renderer rejected commit\");\n    }\n    this.active = candidate;\n  }\n\n  restore(previous: PreparedAvatar | null): void {\n    this.active = previous;\n  }\n\n  async dispose(candidate: PreparedAvatar): Promise<void> {\n    this.disposed.push(candidate.assetId);\n  }\n}\n\nconst asset = (id: string): AvatarManifest => ({\n  id,\n  revision: \"1\",\n  format: \"3d-avatar\",\n  declaredBytes: 1_000_000\n});\n\ndescribe(\"AvatarLoader\", () => {\n  it(\"commits a candidate only after qualification\", async () => {\n    const port = new FakePort();\n    const loader = new AvatarLoader(port, examplePolicy);\n\n    const result = await loader.switchTo(asset(\"avatar-green\"), \"high\");\n\n    expect(result).toEqual({ ok: true, avatarId: \"avatar-green\" });\n    expect(port.active?.assetId).toBe(\"avatar-green\");\n    expect(loader.state).toEqual({\n      tag: \"showing\",\n      avatarId: \"avatar-green\"\n    });\n  });\n\n  it(\"restores the previous avatar when activation fails\", async () => {\n    const port = new FakePort();\n    const loader = new AvatarLoader(port, examplePolicy);\n\n    await loader.switchTo(asset(\"known-good\"), \"high\");\n    port.activationShouldFail = true;\n\n    const result = await loader.switchTo(asset(\"candidate\"), \"high\");\n\n    expect(result.ok).toBe(false);\n    expect(port.active?.assetId).toBe(\"known-good\");\n    expect(loader.state).toMatchObject({\n      tag: \"failed\",\n      candidateId: \"candidate\",\n      stillShowing: \"known-good\"\n    });\n  });\n\n  it(\"does not attempt 3D activation on a disallowed tier\", async () => {\n    const port = new FakePort();\n    const loader = new AvatarLoader(port, examplePolicy);\n\n    const result = await loader.switchTo(asset(\"heavy-avatar\"), \"low\");\n\n    expect(result).toEqual({\n      ok: false,\n      reason: \"3D avatars are disabled for this device tier\"\n    });\n    expect(port.active).toBeNull();\n  });\n});\n```\n\nRun the suite:\n\n```\nnpm test\n```\n\nThese tests prove control-flow invariants. They do not prove that your real avatar renders correctly. That requires integration tests on target hardware.\n\nKeep the platform adapter narrow:\n\n```\nclass TencentBeautyAvatarAdapter implements AvatarPort {\n  async prepare(manifest: AvatarManifest): Promise<PreparedAvatar> {\n    // Load and initialize the avatar using the supported Beauty AR\n    // integration for your selected platform.\n    // Do not attach it to the visible output yet.\n    throw new Error(\"Map to the documented platform integration\");\n  }\n\n  async probe(candidate: PreparedAvatar): Promise<FrameProbe> {\n    // Return observations collected by your application instrumentation.\n    throw new Error(\"Implement application-level frame observations\");\n  }\n\n  activate(candidate: PreparedAvatar): void {\n    // Atomically attach the prepared candidate to visible output.\n    throw new Error(\"Implement documented activation behavior\");\n  }\n\n  restore(previous: PreparedAvatar | null): void {\n    // Restore the previous avatar or the explicit avatar-paused view.\n    throw new Error(\"Implement rollback behavior\");\n  }\n\n  async dispose(candidate: PreparedAvatar): Promise<void> {\n    // Release candidate-specific resources.\n    throw new Error(\"Implement documented cleanup behavior\");\n  }\n}\n```\n\nThis skeleton is intentionally not filled with guessed API names. Use the SDK and platform instructions linked from the official Beauty AR documentation.\n\nThe adapter contract gives that integration a testable meaning:\n\nIf 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.\n\nUnit tests should be followed by controlled failure drills.\n\nRemove a required asset file or provide an invalid revision.\n\nExpected result:\n\nA manifest size check is not an integrity check. In production, validate the actual downloaded bytes and any integrity metadata supplied by your asset pipeline.\n\nSelect avatars A, B, and C while A is still loading.\n\nAlso test this with deliberately delayed downloads rather than relying on fast local assets.\n\nCover the camera, move outside the supported pose, or otherwise reproduce the tracking-loss behavior relevant to your product.\n\nDo 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.\n\nForce the application into its low-device tier.\n\nPossible 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.\n\nInject a failure into the adapter's activation boundary.\n\n`restore(previous)` runs.\nLet activation succeed, then make disposal of the previous asset fail.\n\nThis distinguishes a presentation failure from a resource-lifecycle defect.\n\nA useful qualification policy combines several signals rather than hiding everything under “FPS looks okay.”\n\n| Signal | What it catches | Limitation | \n|---|---|---|\n| Candidate rendered | Missing or invalid output | Does not prove tracking quality | \n| Tracking state | Frozen or detached avatar behavior | Can vary with pose and environment | \n| Frame time | Expensive candidate rendering | Needs device-specific thresholds | \n| Asset revision | Stale or mismatched content | Does not validate runtime behavior | \n| Cleanup result | Resource lifecycle defects | Happens after the visible decision | \n\nFor 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.\n\nAI can be genuinely useful for:\n\nIt has not demonstrated production readiness merely by producing an asset that opens once.\n\nThe 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.\n\nThat 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.\n\nBefore enabling 3D avatar updates in a live Beauty AR session, verify that:\n\nThe 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?”\n\n**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.", "url": "https://wpnews.pro/news/hot-swap-a-3d-avatar-without-publishing-the-broken-frame", "canonical_source": "https://dev.to/susiewang/hot-swap-a-3d-avatar-without-publishing-the-broken-frame-5dgf", "published_at": "2026-09-20 02:12:32+00:00", "updated_at": "2026-09-20 02:24:50.871229+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "generative-ai", "ai-products"], "entities": ["Tencent RTC", "Tencent RTC Beauty AR", "TypeScript", "Vitest", "npm"], "alternates": {"html": "https://wpnews.pro/news/hot-swap-a-3d-avatar-without-publishing-the-broken-frame", "markdown": "https://wpnews.pro/news/hot-swap-a-3d-avatar-without-publishing-the-broken-frame.md", "text": "https://wpnews.pro/news/hot-swap-a-3d-avatar-without-publishing-the-broken-frame.txt", "jsonld": "https://wpnews.pro/news/hot-swap-a-3d-avatar-without-publishing-the-broken-frame.jsonld"}}