DEV Community

LunarDrift
LunarDrift

Posted on

Make GAN Effects Fail in CI, Not on Low-End Devices

A Beauty AR demo can be completely convincing while avoiding the question that matters in production: what happens when this effect reaches a device that should not run it?

That gap is easy to interpret as a skills problem—perhaps you need to learn more rendering internals or test every phone. Usually, the missing skill is narrower and more valuable: expressing rollout decisions as code.

In this tutorial, we will build a small policy gate for a Tencent RTC Beauty AR integration. It will:

  • classify Beauty AR profiles by relative cost;
  • keep GAN, segmentation, and 3D effects away from conservative device tiers;
  • require every expensive profile to have a cheaper fallback;
  • reject cyclic or incomplete fallback chains in GitHub Actions;
  • expose runtime states such as applying, degraded, and blocked;
  • verify the policy without requiring a camera or Beauty AR asset in CI.

The profile fields below belong to our application. They are deliberately not presented as Tencent RTC SDK API names. The final adapter is where each profile must be mapped to the documented integration surface for your target platform.

Why a policy gate belongs in the implementation

Tencent RTC Beauty AR covers real-time beauty filters, makeup, stickers, virtual backgrounds, avatars, gesture recognition, and image or video enhancement. The product overview is here:

https://trtc.io/document/beauty-ar-overview

Those capabilities do not all have the same device cost. Tencent RTC's low-end optimization guide recommends adapting effects by device tier, selecting appropriate performance modes, controlling resolution and frame rate, and disabling expensive capabilities such as segmentation or 3D/GAN effects where necessary:

https://trtc.io/document/66968

The documentation gives us an engineering direction, not a universal tier table. Your application still has to decide:

  1. How a device enters a tier.
  2. Which effects that tier permits.
  3. What replaces an effect when loading or application fails.
  4. Whether a session may upgrade again after degradation.
  5. How configuration changes are reviewed and tested.

We will put decisions 2–5 into reproducible code. Device classification remains an application-specific input because it should be based on measurements from your supported hardware rather than a borrowed benchmark.

The repository we will build

Use Node.js 22 or another version supported by your own toolchain.

beauty-policy-gate/
├── beauty-policy.json
├── runtime.mjs
├── validate-policy.mjs
├── validate-policy.test.mjs
└── .github/
    └── workflows/
        └── beauty-policy.yml
Enter fullscreen mode Exit fullscreen mode

No runtime dependencies are required.

mkdir beauty-policy-gate
cd beauty-policy-gate
npm init -y
Enter fullscreen mode Exit fullscreen mode

Add these scripts to package.json:

{
  "type": "module",
  "scripts": {
    "validate": "node validate-policy.mjs beauty-policy.json",
    "test": "node --test"
  }
}
Enter fullscreen mode Exit fullscreen mode

Describe the rollout as data

Create beauty-policy.json:

{
  "version": 1,
  "entryByTier": {
    "low": "basic",
    "mid": "standard",
    "high": "gan-showcase"
  },
  "tierRules": {
    "low": ["segmentation", "avatar3d", "gan"],
    "mid": ["avatar3d", "gan"],
    "high": []
  },
  "profiles": {
    "basic": {
      "performanceMode": "conservative",
      "outputPreset": "low",
      "effects": [
        { "id": "soft-beauty", "kind": "beauty2d" }
      ],
      "fallback": null
    },
    "standard": {
      "performanceMode": "balanced",
      "outputPreset": "medium",
      "effects": [
        { "id": "soft-beauty", "kind": "beauty2d" },
        { "id": "studio-makeup", "kind": "makeup" }
      ],
      "fallback": "basic"
    },
    "gan-showcase": {
      "performanceMode": "quality",
      "outputPreset": "high",
      "effects": [
        { "id": "portrait-style-a", "kind": "gan" }
      ],
      "fallback": "standard"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Names such as conservative and low are application-level keys. Your native adapter might map them to different SDK settings on Android, iOS, or another supported platform.

The important part is the graph:

high → gan-showcase → standard → basic
mid  → standard ───────────────→ basic
low  → basic
Enter fullscreen mode Exit fullscreen mode

A GAN profile can fail without forcing the application to improvise. Its fallback is already a reviewed product decision.

Validate the graph before merge

Create validate-policy.mjs:

import { readFile, writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";

const COST = {
  beauty2d: 0,
  sticker2d: 1,
  makeup: 1,
  segmentation: 2,
  avatar3d: 3,
  gan: 3
};

export function validatePolicy(policy) {
  const errors = [];
  const profiles = policy?.profiles ?? {};
  const entries = policy?.entryByTier ?? {};
  const rules = policy?.tierRules ?? {};

  if (policy?.version !== 1) {
    errors.push("version must be 1");
  }

  const profileCost = (name) => {
    const profile = profiles[name];
    if (!profile) return Number.POSITIVE_INFINITY;

    return profile.effects.reduce(
      (sum, effect) => sum + (COST[effect.kind] ?? 100),
      0
    );
  };

  for (const [name, profile] of Object.entries(profiles)) {
    if (!Array.isArray(profile.effects)) {
      errors.push(`${name}: effects must be an array`);
      continue;
    }

    const ids = new Set();

    for (const effect of profile.effects) {
      if (!effect.id || !effect.kind) {
        errors.push(`${name}: every effect needs id and kind`);
      }

      if (!(effect.kind in COST)) {
        errors.push(`${name}: unknown effect kind '${effect.kind}'`);
      }

      if (ids.has(effect.id)) {
        errors.push(`${name}: duplicate effect id '${effect.id}'`);
      }
      ids.add(effect.id);
    }

    if (profile.fallback !== null && !profiles[profile.fallback]) {
      errors.push(`${name}: fallback '${profile.fallback}' does not exist`);
    }

    if (
      profile.fallback !== null &&
      profiles[profile.fallback] &&
      profileCost(profile.fallback) >= profileCost(name)
    ) {
      errors.push(`${name}: fallback must have a lower relative cost`);
    }
  }

  for (const [tier, entry] of Object.entries(entries)) {
    if (!profiles[entry]) {
      errors.push(`${tier}: entry profile '${entry}' does not exist`);
      continue;
    }

    const prohibited = new Set(rules[tier] ?? []);
    const visited = new Set();
    let current = entry;

    while (current !== null) {
      if (visited.has(current)) {
        errors.push(`${tier}: fallback cycle reaches '${current}'`);
        break;
      }
      visited.add(current);

      const profile = profiles[current];
      if (!profile) break;

      for (const effect of profile.effects) {
        if (prohibited.has(effect.kind)) {
          errors.push(
            `${tier}: profile '${current}' uses prohibited '${effect.kind}'`
          );
        }
      }

      current = profile.fallback;
    }
  }

  return {
    ok: errors.length === 0,
    checkedProfiles: Object.keys(profiles).length,
    checkedTiers: Object.keys(entries).length,
    errors
  };
}

async function main() {
  const file = process.argv[2] ?? "beauty-policy.json";
  const policy = JSON.parse(await readFile(file, "utf8"));
  const report = validatePolicy(policy);

  await writeFile(
    "beauty-policy-report.json",
    JSON.stringify(report, null, 2) + "\n"
  );

  if (!report.ok) {
    console.error("Beauty policy rejected:");
    for (const error of report.errors) console.error(`- ${error}`);
    process.exitCode = 1;
    return;
  }

  console.log(
    `Beauty policy accepted: ${report.checkedProfiles} profiles, ` +
    `${report.checkedTiers} tiers`
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  await main();
}
Enter fullscreen mode Exit fullscreen mode

The cost values are intentionally ordinal. They are not Tencent RTC performance measurements. Their purpose is to enforce one local invariant: a fallback must be simpler than the profile it replaces.

Run the validator:

npm run validate
Enter fullscreen mode Exit fullscreen mode

Expected output:

Beauty policy accepted: 3 profiles, 3 tiers
Enter fullscreen mode Exit fullscreen mode

Now introduce a production-risking mistake:

"low": "gan-showcase"
Enter fullscreen mode Exit fullscreen mode

The command should fail with an actionable reason instead of merely sending a generic workflow failure notification:

Beauty policy rejected:
- low: profile 'gan-showcase' uses prohibited 'gan'
- low: profile 'standard' uses prohibited 'makeup'
Enter fullscreen mode Exit fullscreen mode

That second error follows from our sample policy because the low tier prohibits anything listed in its rule. Adjust tierRules to match your measured and reviewed policy; do not copy these categories as universal hardware facts.

Put the same check in GitHub Actions

Create .github/workflows/beauty-policy.yml:

name: Validate Beauty AR policy

on:
  pull_request:
    paths:
      - "beauty-policy.json"
      - "validate-policy.mjs"
      - "validate-policy.test.mjs"
      - ".github/workflows/beauty-policy.yml"
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  validate:
    runs-on: ubuntu-latest
    timeout-minutes: 5

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "22"

      - name: Run policy tests
        run: npm test

      - name: Validate production policy
        run: npm run validate

      - name: Publish validation report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: beauty-policy-report
          path: beauty-policy-report.json
          if-no-files-found: warn
Enter fullscreen mode Exit fullscreen mode

Notice what is absent: a daily schedule. A scheduled job is useful when an external dependency can drift, but this validator only checks repository state. Running it every night would create noise without finding a new class of failure.

If you later validate a remote asset registry or remotely hosted policy, a schedule may become justified. Give that check its own timeout and distinguish “registry unavailable” from “policy invalid.”

Test the dangerous configuration changes

Create validate-policy.test.mjs:

import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { validatePolicy } from "./validate-policy.mjs";

const original = JSON.parse(
  await readFile(new URL("./beauty-policy.json", import.meta.url), "utf8")
);

const copy = () => structuredClone(original);

test("accepts the reviewed policy", () => {
  assert.equal(validatePolicy(copy()).ok, true);
});

test("rejects GAN as the low-tier entry", () => {
  const policy = copy();
  policy.entryByTier.low = "gan-showcase";

  const result = validatePolicy(policy);

  assert.equal(result.ok, false);
  assert.ok(result.errors.some((e) => e.includes("prohibited 'gan'")));
});

test("rejects a fallback cycle", () => {
  const policy = copy();
  policy.profiles.basic.fallback = "gan-showcase";

  const result = validatePolicy(policy);

  assert.equal(result.ok, false);
  assert.ok(
    result.errors.some(
      (e) => e.includes("fallback cycle") || e.includes("lower relative cost")
    )
  );
});

test("rejects a missing fallback profile", () => {
  const policy = copy();
  policy.profiles["gan-showcase"].fallback = "does-not-exist";

  const result = validatePolicy(policy);

  assert.equal(result.ok, false);
  assert.ok(result.errors.some((e) => e.includes("does not exist")));
});
Enter fullscreen mode Exit fullscreen mode

Run everything locally:

npm test
npm run validate
Enter fullscreen mode Exit fullscreen mode

CI now proves that the rollout graph is structurally safe. It does not prove that an asset can load, tracking will be stable, or the selected output settings will meet a frame-rate objective on real hardware. Those belong in device testing.

Apply profiles through explicit runtime states

A valid policy can still encounter missing assets, initialization errors, or device pressure. The runtime needs to expose that distinction.

Create runtime.mjs:

export class BeautyProfileController {
  #adapter;
  #policy;
  #revision = 0;
  #queue = Promise.resolve();

  state = { kind: "idle" };

  constructor(adapter, policy, onState = () => {}) {
    this.#adapter = adapter;
    this.#policy = policy;
    this.onState = onState;
  }

  applyTier(tier) {
    const revision = ++this.#revision;

    this.#queue = this.#queue.then(() => this.#run(tier, revision));
    return this.#queue;
  }

  async #run(tier, revision) {
    const entry = this.#policy.entryByTier[tier];

    if (!entry) {
      return this.#set({ kind: "blocked", reason: "unknown-tier", tier });
    }

    this.#set({ kind: "resolving", tier, profile: entry });

    let current = entry;
    let degraded = false;
    const failures = [];

    while (current !== null) {
      if (revision !== this.#revision) return this.state;

      const profile = this.#policy.profiles[current];
      this.#set({ kind: "applying", tier, profile: current, degraded });

      try {
        // These are application-owned adapter methods, not SDK API names.
        await this.#adapter.disableAllEffects();
        await this.#adapter.configureOutput(profile.outputPreset);
        await this.#adapter.setPerformanceMode(profile.performanceMode);

        for (const effect of profile.effects) {
          await this.#adapter.enableEffect(effect);
        }

        if (revision !== this.#revision) return this.state;

        return this.#set({
          kind: degraded ? "degraded" : "ready",
          tier,
          profile: current,
          failures
        });
      } catch (error) {
        failures.push({
          profile: current,
          message: error instanceof Error ? error.message : String(error)
        });
        degraded = true;
        current = profile.fallback;
      }
    }

    await this.#adapter.disableAllEffects().catch(() => {});

    return this.#set({
      kind: "blocked",
      tier,
      reason: "no-profile-could-be-applied",
      failures
    });
  }

  #set(next) {
    this.state = next;
    this.onState(next);
    return next;
  }
}
Enter fullscreen mode Exit fullscreen mode

The adapter boundary might look like this:

const beautyAdapter = {
  async disableAllEffects() {
    // Map to the documented cleanup operations for your platform.
  },

  async configureOutput(outputPreset) {
    // Map your preset to the reviewed resolution/frame-rate policy.
  },

  async setPerformanceMode(performanceMode) {
    // Map the application key to the supported platform configuration.
  },

  async enableEffect(effect) {
    // Resolve effect.id and apply it through the platform integration.
  }
};
Enter fullscreen mode Exit fullscreen mode

Do not copy those method names into SDK calls. They are a narrow port that prevents platform-specific code from becoming the source of rollout policy.

The state distinction also gives the UI honest options:

State UI behavior
resolving Keep controls disabled briefly while selecting the profile
applying Show that the requested look is being prepared
ready Show the selected effect as active
degraded Explain that a compatible effect was substituted
blocked Show an effects-unavailable state rather than pretending success

Whether the camera remains available when all effects fail is a separate consent and product decision. Do not silently treat raw camera output as the universal fallback.

How to choose a tier without inventing a universal benchmark

Use a two-stage decision:

1. Establish a conservative initial classification

Base it on signals your platform can obtain reliably and on a device matrix your team has actually tested. Unknown devices should enter a conservative profile rather than receiving GAN merely because they are absent from a deny list.

2. Confirm the classification with session measurements

Observe the rendered result, not just whether configuration calls succeeded. Depending on your platform, useful application measurements can include:

  • delivered or rendered frame cadence;
  • sustained frame gaps;
  • main-thread pressure;
  • thermal or memory warnings exposed by the operating system;
  • repeated renderer or asset-loading failures.

Define thresholds from your own supported-device tests. The official guide provides the optimization direction, but it does not justify one universal frame-rate threshold for every application.

For a simpler and more predictable UX, make degradation one-way during a session. Once a GAN profile falls back to standard, do not automatically oscillate back every time a short measurement window improves. Reconsider the tier on the next session or after an explicit user retry.

The trade-off is straightforward:

  • Automatic upgrade may restore richer effects sooner, but risks visible switching and repeated asset work.
  • Session-sticky fallback is less ambitious, but easier to explain, test, and support.

Failures CI cannot settle for you

The policy passes, but the GAN asset is unavailable

The static validator checks references and transitions, not remote availability. Add a staging check that resolves every production asset identifier. Keep availability errors separate from policy errors so an outage does not encourage someone to weaken the tier rules.

At runtime, gan-showcase should fail into standard. Record which profile failed and which one became active.

A remote configuration bypasses the repository

If production policy can be edited elsewhere, a green pull request check is not sufficient. Run the same validator in the publishing service and again when the client receives a new policy. Reject invalid revisions while preserving the last known-good policy.

Include a policy revision in logs so a device report can be tied to the configuration that produced it.

The fallback itself fails

A fallback is not guaranteed merely because it is cheaper. The controller continues until it reaches a working terminal profile. If basic also fails, it enters blocked and attempts to disable effects.

That state needs a user-facing outcome. “Effect unavailable” is more truthful than leaving a spinner active indefinitely.

Two profile requests arrive quickly

The sample controller serializes adapter operations and assigns every request a revision. This avoids concurrent calls fighting over renderer state. The cost is that a new request can wait for an older adapter operation to settle.

If your platform provides documented cancellation semantics, you can add cancellation inside the adapter. Do not simulate cancellation by ignoring a promise while allowing its side effects to keep modifying the renderer.

The GitHub Action fails repeatedly

A workflow failure should identify a policy invariant, not become background email noise. Keep the validation local and deterministic, publish the report even on failure, use a timeout, and avoid a schedule unless an external input can actually drift.

Release verification checklist

Before enabling the GAN profile for a production cohort, verify all three layers.

Repository policy

  • [ ] Every tier has an existing entry profile.
  • [ ] Low and mid tiers cannot reach prohibited effects through fallback chains.
  • [ ] Every fallback has a lower application-defined relative cost.
  • [ ] Every chain terminates without a cycle.
  • [ ] Pull requests run both tests and production-policy validation.

Runtime behavior

  • [ ] A failed GAN application reaches the expected cheaper profile.
  • [ ] The UI distinguishes ready, degraded, and blocked.
  • [ ] Rapid profile requests cannot leave mixed effects active.
  • [ ] An unknown tier fails conservatively.
  • [ ] A total effect failure follows an explicit camera and consent policy.

Real-device verification

  • [ ] Tests include the actual camera, output settings, and effect combination used in sessions.
  • [ ] Device-tier thresholds come from measured supported hardware.
  • [ ] Asset-loading failure is tested separately from sustained performance pressure.
  • [ ] The active policy revision and selected profile are observable.
  • [ ] Degradation does not oscillate repeatedly during one session.

The useful reframing

You do not need to prove that every device can run the most impressive Beauty AR effect. You need to prove that every supported device has a reviewed path when it cannot.

That is the difference between a demo and an implementation: the demo showcases the successful state; the implementation names all the states around it, assigns authority to configuration, and makes failure reproducible before release.

Discussion

Would you keep Beauty AR fallback sticky for the whole session, or allow a user-triggered retry after the device recovers? The answer depends less on rendering ambition than on how visible and disruptive switching profiles is in your experience.

Relationship disclosure: I have a connection to Tencent RTC, and I used the official Tencent RTC documentation linked above as the implementation reference for this article.

Top comments (0)