A Beauty AR proof of concept can look perfect during a 30-second desk test and still struggle in the product you intend to ship.
The tension is not simply “performance versus visual quality.” It is deciding which effects must remain available on which devices, under what sustained workload, and what the application should do when that contract cannot be met.
A useful test therefore needs more than an average frame-rate number. It should:
- exercise representative combinations of effects;
- run long enough to expose sustained degradation;
- distinguish setup failure from rendering pressure;
- invalidate measurements taken under bad test conditions;
- verify that a lower-cost profile actually recovers;
- preserve enough evidence to explain a result later.
This tutorial builds that test boundary in TypeScript. It does not depend on invented Tencent RTC API names: the SDK-specific calls stay behind an adapter that you connect using the documentation for your target platform.
Tencent RTC Beauty AR covers real-time capabilities such as beauty filters, makeup, stickers, virtual backgrounds, avatars, gesture recognition, and image/video enhancement. See the official Beauty AR overview for the supported scenario surface.
Start with a workload contract, not a benchmark loop
Testing every effect independently is insufficient. Users combine effects, move through changing scenes, and keep sessions open longer than a demo.
Define a small matrix based on what your product will actually expose:
| Case | Profile | Scene | Purpose |
|---|---|---|---|
| Baseline | Camera without optional effects | Controlled lighting | Separates camera/render cost from effects |
| Common path | Your default beauty and makeup combination | Normal movement | Tests the configuration most users receive |
| Expensive path | A shipped segmentation, 3D, GAN, avatar, or background feature | Movement plus background detail | Exposes the costliest supported experience |
| Sustained path | Common or expensive profile | Continuous session | Detects deterioration hidden by short averages |
| Recovery | Lower-cost profile | Same scene as the failing case | Proves graceful degradation works |
Only include features your application actually offers. A synthetic “everything enabled” configuration can be useful as a stress case, but it should not replace representative workloads.
Device grouping also needs care. A user-agent string is not a performance measurement. Maintain product-owned tiers based on real-device results, and keep an unknown tier that starts conservatively until you have evidence.
Represent the test policy as data
Create a minimal browser TypeScript project:
mkdir beauty-ar-performance-lab
cd beauty-ar-performance-lab
npm init -y
npm install -D typescript vite
npx tsc --init
mkdir src
Add src/policy.ts:
export type DeviceTier = "entry" | "standard" | "high" | "unknown";
export type EffectProfile = {
id: string;
width: number;
height: number;
targetFps: number;
effects: string[];
performanceMode: "quality" | "balanced" | "performance";
};
export type AcceptanceBudget = {
minimumDeliveryRatio: number;
maximumP95OutputGapMs: number;
maximumLongTaskMs: number;
maximumApplyMs: number;
maximumRecoveryMs: number;
};
export type TestCase = {
id: string;
tier: DeviceTier;
durationMs: number;
profile: EffectProfile;
recoveryProfile: EffectProfile;
budget: AcceptanceBudget;
};
Here is an example policy—not a Tencent RTC benchmark or a universal recommendation:
import type { TestCase } from "./policy";
export const commonEntryDeviceCase: TestCase = {
id: "entry-common-sustained",
tier: "entry",
durationMs: 120_000,
profile: {
id: "common-beauty",
width: 960,
height: 540,
targetFps: 24,
effects: ["beauty", "makeup"],
performanceMode: "balanced"
},
recoveryProfile: {
id: "reduced-beauty",
width: 640,
height: 360,
targetFps: 20,
effects: ["beauty"],
performanceMode: "performance"
},
budget: {
minimumDeliveryRatio: 0.9,
maximumP95OutputGapMs: 100,
maximumLongTaskMs: 150,
maximumApplyMs: 5_000,
maximumRecoveryMs: 3_000
}
};
Replace every number with a product decision derived from your expected experience and tested devices. Keeping the policy in version control is more important than copying any particular threshold.
It lets a pull request answer a concrete question: did the workload change, did the acceptance contract change, or did the implementation regress?
Put the Beauty AR integration behind a measurable boundary
The harness needs to observe the application’s actual output path. Counting requestAnimationFrame calls only measures browser scheduling; it does not prove that processed Beauty AR frames were produced.
Define a narrow adapter in src/adapter.ts:
import type { EffectProfile } from "./policy";
export type OutputFrameListener = (completedAt: number) => void;
export interface BeautyArAdapter {
prepare(): Promise<void>;
applyProfile(profile: EffectProfile): Promise<void>;
onOutputFrame(listener: OutputFrameListener): () => void;
dispose(): Promise<void>;
}
Implement this interface at the point where your application knows a processed frame has reached its intended rendering output. If your integration does not expose a completion callback, instrument the application-owned render destination rather than substituting UI animation ticks.
Map performanceMode, resolution, frame rate, and effect selection to the supported configuration surface of the Tencent RTC Beauty AR SDK for your platform. The official Low-End Device Performance Optimization Practice Guide recommends adapting by device tier, selecting suitable performance modes, controlling resolution and frame rate, and disabling costly segmentation or 3D/GAN effects where necessary.
The adapter should reject applyProfile() when configuration fails. It must not report success merely because the old profile is still producing frames.
Run tests through explicit states
A performance test is itself an asynchronous application. If its state is implicit, a timed-out setup or hidden browser tab can accidentally become a passing result.
Add src/harness.ts:
import type { BeautyArAdapter } from "./adapter";
import type { EffectProfile, TestCase } from "./policy";
export type RunState =
| "idle"
| "preparing"
| "running"
| "recovering"
| "complete"
| "invalid"
| "failed";
export type Metrics = {
elapsedMs: number;
outputFrames: number;
deliveryRatio: number;
p95OutputGapMs: number;
maximumLongTaskMs: number;
firstWindowFps: number;
lastWindowFps: number;
};
export type RunResult = {
caseId: string;
state: RunState;
profileId: string;
reasons: string[];
metrics?: Metrics;
recoveryMs?: number;
};
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
async function withTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
label: string
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`${label} exceeded ${timeoutMs}ms`)),
timeoutMs
);
});
try {
return await Promise.race([operation, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
function percentile(values: number[], fraction: number): number {
if (values.length === 0) return Number.POSITIVE_INFINITY;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.min(
sorted.length - 1,
Math.floor(sorted.length * fraction)
);
return sorted[index];
}
function countBetween(frames: number[], start: number, end: number): number {
return frames.filter((time) => time >= start && time < end).length;
}
export class PerformanceHarness {
private state: RunState = "idle";
constructor(private readonly adapter: BeautyArAdapter) {}
getState(): RunState {
return this.state;
}
async run(test: TestCase): Promise<RunResult> {
const result: RunResult = {
caseId: test.id,
profileId: test.profile.id,
state: "idle",
reasons: []
};
if (document.visibilityState !== "visible") {
return { ...result, state: "invalid", reasons: ["page-not-visible"] };
}
const frames: number[] = [];
const longTasks: number[] = [];
let becameHidden = false;
const visibilityListener = () => {
if (document.visibilityState !== "visible") becameHidden = true;
};
document.addEventListener("visibilitychange", visibilityListener);
const unsubscribe = this.adapter.onOutputFrame((time) => {
frames.push(time);
});
let observer: PerformanceObserver | undefined;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) longTasks.push(entry.duration);
});
observer.observe({ entryTypes: ["longtask"] });
} catch {
// Long Task observation is supplementary and may be unavailable.
}
try {
this.state = "preparing";
await withTimeout(
this.adapter.prepare(),
test.budget.maximumApplyMs,
"prepare"
);
await withTimeout(
this.adapter.applyProfile(test.profile),
test.budget.maximumApplyMs,
"profile application"
);
this.state = "running";
const startedAt = performance.now();
await sleep(test.durationMs);
const endedAt = performance.now();
if (becameHidden) {
this.state = "invalid";
return {
...result,
state: "invalid",
reasons: ["page-became-hidden"]
};
}
const elapsedMs = endedAt - startedAt;
const activeFrames = frames.filter(
(time) => time >= startedAt && time <= endedAt
);
const expectedFrames = (elapsedMs / 1000) * test.profile.targetFps;
const deliveryRatio =
expectedFrames === 0 ? 0 : activeFrames.length / expectedFrames;
const gaps = activeFrames.slice(1).map(
(time, index) => time - activeFrames[index]
);
const windowMs = Math.min(10_000, elapsedMs / 2);
const firstWindowFps =
countBetween(activeFrames, startedAt, startedAt + windowMs) /
(windowMs / 1000);
const lastWindowFps =
countBetween(activeFrames, endedAt - windowMs, endedAt) /
(windowMs / 1000);
const metrics: Metrics = {
elapsedMs,
outputFrames: activeFrames.length,
deliveryRatio,
p95OutputGapMs: percentile(gaps, 0.95),
maximumLongTaskMs: Math.max(0, ...longTasks),
firstWindowFps,
lastWindowFps
};
if (activeFrames.length === 0) result.reasons.push("no-output-frames");
if (deliveryRatio < test.budget.minimumDeliveryRatio) {
result.reasons.push("delivery-ratio-below-budget");
}
if (metrics.p95OutputGapMs > test.budget.maximumP95OutputGapMs) {
result.reasons.push("output-gap-above-budget");
}
if (
observer &&
metrics.maximumLongTaskMs > test.budget.maximumLongTaskMs
) {
result.reasons.push("long-task-above-budget");
}
if (result.reasons.length > 0) {
this.state = "recovering";
const recoveryStartedAt = performance.now();
await withTimeout(
this.adapter.applyProfile(test.recoveryProfile),
test.budget.maximumRecoveryMs,
"recovery profile"
);
result.recoveryMs = performance.now() - recoveryStartedAt;
}
this.state = "complete";
return {
...result,
state: "complete",
metrics
};
} catch (error) {
this.state = "failed";
return {
...result,
state: "failed",
reasons: [error instanceof Error ? error.message : "unknown-error"]
};
} finally {
observer?.disconnect();
unsubscribe();
document.removeEventListener("visibilitychange", visibilityListener);
}
}
}
This code deliberately keeps several outcomes separate:
-
completewith no reasons: the measured profile met the configured gate. -
completewith reasons: the profile missed the gate, but the configured recovery was applied. -
invalid: environmental conditions made the measurement unusable. -
failed: setup, profile application, or recovery did not complete reliably.
Do not collapse invalid and failed into a zero-frame sample. Doing so makes infrastructure problems look like performance regressions—or, worse, allows missing samples to disappear from a dashboard.
A recovery is not verified merely because configuration resolved
The harness above confirms that the recovery profile was accepted within its deadline. For a production gate, add a second measurement window after recovery and require fresh output frames under the recovery profile.
That distinction catches an important failure mode:
- The expensive profile falls below budget.
- The application requests a reduced profile.
- The configuration promise resolves.
- Rendering remains stalled or continues using the previous profile.
A robust adapter should expose the currently active profile only after the new configuration is observable at the output boundary. If your platform integration cannot prove that directly, record the limitation and verify the visual state in a device test.
Recovery also needs a product decision. The low-end optimization guide supports strategies such as selecting a performance-oriented mode, controlling resolution and frame rate, and removing costly segmentation or 3D/GAN effects. It does not mean every application should use the same order.
A practical decision table might look like this:
| Product priority | First reduction | Preserve as long as possible |
|---|---|---|
| Video-call clarity | Optional backgrounds or complex effects | Stable face rendering and communication |
| Creator visual style | Resolution or frame-rate target within an accepted range | Selected makeup/look |
| Avatar-led experience | Secondary embellishments | Avatar identity and tracking continuity |
| Entry-device reach | Expensive segmentation, 3D, or GAN effects | A simpler beauty profile |
The right ladder depends on why the user enabled Beauty AR. Performance policy should preserve that intent rather than merely selecting the cheapest effect.
Run a controlled protocol on real devices
Automation provides repeatability, but a realistic Beauty AR test still needs controlled physical conditions.
For each device and test case:
- Record the application build, Beauty AR integration version, operating system, device model, and policy revision.
- Return the device to a documented starting condition.
- Keep power and charging conditions consistent across comparable runs.
- Use the same camera, lighting, framing, movement script, and background complexity.
- Run the baseline before the effect workload.
- Run the representative profile for the configured duration.
- If it misses the gate, apply the recovery profile without restarting the session.
- Confirm both measured recovery and visual correctness.
- Repeat enough times to distinguish a repeatable result from a one-off interruption.
- Store raw frame timestamps, state transitions, failure reasons, and policy version—not just the final pass/fail label.
If your application can accept a controlled video source, that can improve repeatability. It still does not replace camera-path validation unless the controlled source follows the same capture and processing path used in production.
Interpret four signals together
No single number explains a real-time visual experience.
1. Delivery ratio
This compares observed output frames with the product’s configured target. It answers whether the pipeline delivered approximately the expected amount of output over the whole window.
It can hide bursts and freezes, so never use it alone.
2. P95 output gap
The high-percentile interval between completed frames exposes visible stalls that an average can smooth over.
Inspect the raw timeline when this fails. A single asset-loading pause and continuous processing pressure require different fixes.
3. First-window versus last-window rate
Comparing early and late windows helps reveal sustained deterioration. It does not identify thermal throttling by itself; it only shows that later behavior differs from earlier behavior.
Avoid claiming a thermal cause unless you have device evidence supporting it.
4. Main-thread long tasks
Long tasks can explain UI contention, but they are supplementary. Browser support varies, and a quiet main thread does not prove the media/effect pipeline is healthy.
The rendered-output signal remains the primary measurement.
Failure cases that should change the test design
The test passes while the video visibly freezes
You are probably counting UI animation callbacks or input submissions instead of completed output frames. Move instrumentation to the final application-owned render boundary.
Asset loading is included in one run but cached in another
Separate cold preparation, profile application, and sustained rendering into different spans. Test cold and warm behavior intentionally instead of mixing them.
A hidden tab reports terrible performance
Mark the run invalid. Browsers may throttle background work, so the result does not represent an active session.
The average is acceptable, but the end of the run is not
Compare fixed early and late windows and retain the timeline. A whole-run average can hide progressive deterioration.
Recovery lowers cost but breaks the experience
A technically smooth fallback may remove the feature the user chose. Add visual assertions or a manual verification card for identity, tracking, background behavior, and effect correctness.
Profile application fails while old frames continue
Do not treat continued output as successful reconfiguration. Record the requested and confirmed active profile separately in your adapter.
One device classification keeps producing mixed results
Do not keep widening the thresholds until it passes. Split the tier, inspect environmental differences, or move the device to a more conservative default.
A release gate worth reviewing
Before accepting a Beauty AR performance change, verify that:
- [ ] The workload represents a user-facing effect combination.
- [ ] Baseline and effect-enabled runs use the same scene protocol.
- [ ] Output completion—not merely UI activity—is instrumented.
- [ ] Setup, active measurement, recovery, invalidation, and failure are distinct states.
- [ ] Hidden or interrupted runs cannot pass.
- [ ] Cold preparation and sustained rendering are reported separately.
- [ ] Raw timestamps and policy revisions are retained.
- [ ] Early and late windows are compared.
- [ ] The recovery profile is measured after application.
- [ ] Visual correctness is checked after degradation.
- [ ] Thresholds are owned by the product, not copied from a generic benchmark.
- [ ] Unknown devices receive an explicit policy.
The useful shift is to stop asking, “How fast is this beauty effect?”
Ask instead: Which experience contract does this device sustain, how do we know, and what verified experience replaces it when it cannot?
That question produces a test you can reproduce, a failure you can explain, and a fallback your product team can review.
Discussion
How do you currently detect Beauty AR degradation: rendered-frame instrumentation, device telemetry, visual QA, or user reports? The interesting design choice is not only the threshold, but which part of the experience your recovery ladder preserves.
Relationship disclosure: I have a relationship with Tencent RTC. Official Tencent RTC documentation was used as the implementation reference for this article.
Top comments (0)