<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Team Banuba</title>
    <description>The latest articles on DEV Community by Team Banuba (@team_banuba).</description>
    <link>https://dev.to/team_banuba</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4082753%2Fc1c62964-e75a-4e5c-a2b1-4d1001031685.png</url>
      <title>DEV Community: Team Banuba</title>
      <link>https://dev.to/team_banuba</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/team_banuba"/>
    <language>en</language>
    <item>
      <title>Face AR SDK Integration on Android and iOS: 6 Steps From Token to First Effect</title>
      <dc:creator>Team Banuba</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:37:46 +0000</pubDate>
      <link>https://dev.to/team_banuba/face-ar-sdk-integration-on-android-and-ios-6-steps-from-token-to-first-effect-5lk</link>
      <guid>https://dev.to/team_banuba/face-ar-sdk-integration-on-android-and-ios-6-steps-from-token-to-first-effect-5lk</guid>
      <description>&lt;p&gt;Adding face AR to an existing mobile app is a six-step integration that touches two code files per platform, plus your build files. Banuba Face AR SDK is a real-time, on-device face tracking and AR effects SDK. Per Banuba's own technical specification it tracks a single face at 30 FPS on the Android High, iOS Mid and iOS High device tiers and at 25 FPS on Android Low, within a head-angle range of 80 degrees. The full table is in step 6.&lt;/p&gt;

&lt;p&gt;The six steps are: get a client token, add the SDK packages, initialize once at app start, connect the camera to a player and an output view, load an effect, and handle the lifecycle. This guide is for a mobile developer who already ships an Android or iOS app and wants a face filter running in the camera view. The code below is taken from the official Banuba sample repositories for &lt;a href="https://github.com/Banuba/banuba-sdk-android-samples" rel="noopener noreferrer"&gt;Android&lt;/a&gt; and &lt;a href="https://github.com/Banuba/banuba-sdk-ios-samples" rel="noopener noreferrer"&gt;iOS&lt;/a&gt;, so every call is one you can open and run. We build the SDK, and every figure named below comes from our own documentation; this guide contains no benchmark of our own.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Of7-xNDYknY" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  What do you need before you add a face AR SDK to an app?
&lt;/h2&gt;

&lt;p&gt;Four things, and all of them are checkable before you write a line.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A device and OS floor. Banuba Face AR SDK supports Android from 8.0 (API level 26) and iOS from 13.0, and needs OpenGL ES 3.0 or higher on any mobile platform, per the &lt;a href="https://docs.banuba.com/far-sdk/tutorials/capabilities/system_requirements" rel="noopener noreferrer"&gt;system requirements&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A camera. The iOS sample opens the front camera at the 1280x720 preset.&lt;/li&gt;
&lt;li&gt;A language. The SDK exposes Kotlin and Java on Android, Swift and Objective-C on iOS.&lt;/li&gt;
&lt;li&gt;A client token, which is step 1.
One decision belongs here rather than later. If your app also targets Flutter or React Native, the same SDK ships as Banuba-maintained plugins, and our earlier walkthrough on &lt;a href="https://medium.com/@banuba/integrating-augmented-reality-features-to-react-native-apps-afb2eae71962" rel="noopener noreferrer"&gt;adding AR features to a React Native app&lt;/a&gt; covers that path. This guide stays native.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Get a client token and decide where it lives
&lt;/h2&gt;

&lt;p&gt;Banuba Face AR SDK does nothing without a token. There are two kinds, per the &lt;a href="https://docs.banuba.com/far-sdk/tutorials/capabilities/token_management" rel="noopener noreferrer"&gt;token management&lt;/a&gt; page: a demo token for the trial, valid for 14 days and activating all SDK features, and a commercial token issued after payment, valid for the prepaid period and activating the features in your license. You request the demo token through the form on the &lt;a href="https://www.banuba.com/facear-sdk/face-filters" rel="noopener noreferrer"&gt;Face AR SDK page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The decision at this step is storage. Banuba recommends storing the token on your server, because it makes renewal faster. If the token is compiled into the app, every renewal means a new build submitted to the App Store or Google Play.&lt;/p&gt;

&lt;p&gt;Where this step fails: an expired token. Banuba documents three states. While the token is valid, the SDK works as expected. For one month after expiry, the SDK works but shows a Banuba watermark. After that month, the functionality stops while the rest of your app keeps running. A demo token in a live app hits the first of those states 14 days after you got it, which is why the docs say not to ship one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Add the SDK packages, and only the ones you need
&lt;/h2&gt;

&lt;p&gt;Banuba Face AR SDK is split into packages, and your app pulls the ones its features need. Three rules from the &lt;a href="https://docs.banuba.com/far-sdk/tutorials/development/installation" rel="noopener noreferrer"&gt;installation page&lt;/a&gt; decide the step.&lt;/p&gt;

&lt;p&gt;First, the API package is always required. On Android that is &lt;code&gt;com.banuba.sdk:sdk_api&lt;/code&gt;, on iOS it is &lt;code&gt;BNBSdkApi&lt;/code&gt;. Second, face tracking lives in its own package, &lt;code&gt;face_tracker&lt;/code&gt; on Android and &lt;code&gt;BNBFaceTracker&lt;/code&gt; on iOS, and the docs say to include it whenever you deal with tracking. Third, every package must be the same version. Banuba warns that packages of different versions, even minor ones, may conflict or work incorrectly together.&lt;/p&gt;

&lt;p&gt;On Android, add the Banuba Maven repository in &lt;code&gt;settings.gradle.kts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;dependencyResolutionManagement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;repositories&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;google&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;mavenCentral&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;maven&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"BanubaMaven"&lt;/span&gt;
            &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://nexus.banuba.net/repository/maven-releases"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then declare the packages. Note the underscores in the artifact names and the exact version string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;bnbVersion&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.18.5"&lt;/span&gt;

&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Always required&lt;/span&gt;
    &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.banuba.sdk:sdk_api:$bnbVersion"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// Face tracking is used in most effects&lt;/span&gt;
    &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.banuba.sdk:face_tracker:$bnbVersion"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Add only what your effects actually use:&lt;/span&gt;
    &lt;span class="c1"&gt;// api("com.banuba.sdk:background:$bnbVersion")   // background replacement&lt;/span&gt;
    &lt;span class="c1"&gt;// api("com.banuba.sdk:makeup:$bnbVersion")       // makeup&lt;/span&gt;
    &lt;span class="c1"&gt;// api("com.banuba.sdk:eyes:$bnbVersion")         // eye effects&lt;/span&gt;
    &lt;span class="c1"&gt;// api("com.banuba.sdk:lips:$bnbVersion")         // lipstick&lt;/span&gt;
    &lt;span class="c1"&gt;// api("com.banuba.sdk:hair:$bnbVersion")         // hair color&lt;/span&gt;
    &lt;span class="c1"&gt;// api("com.banuba.sdk:skin:$bnbVersion")         // skin retouch&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin the version rather than writing &lt;code&gt;1.18.+&lt;/code&gt;. A dynamic version resolves at build time, so two machines, or the same machine a week apart, can end up on different patch releases. That is the exact situation the docs warn about when they say every package must be on the same version.&lt;/p&gt;

&lt;p&gt;For a face filter alone, &lt;code&gt;sdk_api&lt;/code&gt; plus &lt;code&gt;face_tracker&lt;/code&gt; is the whole list. The &lt;code&gt;banuba_sdk&lt;/code&gt; package depends on everything, and the docs describe it as the choice when you do not care about size.&lt;/p&gt;

&lt;p&gt;On iOS, add the Swift Package Manager packages through &lt;strong&gt;File &amp;gt; Add Package Dependencies&lt;/strong&gt;, starting with &lt;code&gt;https://github.com/sdk-banuba/BNBSdkApi&lt;/code&gt;, and add the feature packages from the &lt;code&gt;sdk-banuba&lt;/code&gt; repositories at the same version. CocoaPods is also supported through a custom repository listed in the &lt;a href="https://github.com/sdk-banuba/banuba-sdk-podspecs" rel="noopener noreferrer"&gt;podspecs repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Where this step fails: a feature that renders wrong or not at all. The installation page says to read the application logs in that case to find which package is missing. The second failure is a version mismatch between two packages, which the same page says can break features without an obvious error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Initialize the SDK once at app start
&lt;/h2&gt;

&lt;p&gt;Initialization is a single call with the token, placed in the application class so it runs once before any camera screen opens.&lt;/p&gt;

&lt;p&gt;Android, from the sample's &lt;code&gt;Application.kt&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.banuba.sdk.manager.BanubaSdkManager&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;BANUBA_CLIENT_TOKEN&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nc"&gt;Place&lt;/span&gt; &lt;span class="n"&gt;your&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="n"&gt;here&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Application&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;android&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Application&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nc"&gt;BanubaSdkManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BANUBA_CLIENT_TOKEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;iOS, from the sample's &lt;code&gt;AppDelegate.swift&lt;/code&gt;. The &lt;code&gt;resourcePath&lt;/code&gt; array tells the SDK where to look for effects, and the sample points it at an &lt;code&gt;effects&lt;/code&gt; folder inside the app bundle and at the bundle root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;UIKit&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;BNBSdkApi&lt;/span&gt;

&lt;span class="kd"&gt;@UIApplicationMain&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;AppDelegate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIResponder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;UIApplicationDelegate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;banubaClientToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;#Place&lt;/span&gt; &lt;span class="n"&gt;your&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="n"&gt;here&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIWindow&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;application&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;didFinishLaunchingWithOptions&lt;/span&gt; &lt;span class="nv"&gt;launchOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;LaunchOptionsKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]?&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;BanubaSdkManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;resourcePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="kt"&gt;Bundle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bundlePath&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/effects"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="kt"&gt;Bundle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bundlePath&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="nv"&gt;clientTokenString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;banubaClientToken&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;#Place your token here#&amp;gt;&lt;/code&gt; placeholder is literally what the samples ship, and neither file compiles until you replace it. Put in the value from step 1, or the call that fetches it from your server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Connect the camera, the player and the output view
&lt;/h2&gt;

&lt;p&gt;This is the step that turns the SDK on. Three objects do the work on both platforms: a camera device that produces frames, a &lt;code&gt;Player&lt;/code&gt; that runs the pipeline, and an output that displays the result. The pattern is &lt;code&gt;player.use(input, output)&lt;/code&gt;, then &lt;code&gt;play()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Android, from the sample's &lt;code&gt;MainActivity.kt&lt;/code&gt;, trimmed to the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;android.os.Bundle&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;android.view.SurfaceView&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.banuba.sdk.input.CameraDevice&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.banuba.sdk.input.CameraInput&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.banuba.sdk.output.SurfaceOutput&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.banuba.sdk.player.Player&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MainActivity&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BaseActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;surfaceView&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LazyThreadSafetyMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NONE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;findViewById&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SurfaceView&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;surfaceView&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// The player executes the main pipeline&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;player&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LazyThreadSafetyMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NONE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Player&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// This camera device will pass frames to the CameraInput&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cameraDevice&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LazyThreadSafetyMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NONE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;CameraDevice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;requireNotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;applicationContext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="nd"&gt;@MainActivity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// The result will be displayed on the surface&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;surfaceOutput&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LazyThreadSafetyMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NONE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;SurfaceOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;surfaceView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;holder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;savedInstanceState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Bundle&lt;/span&gt;&lt;span class="p"&gt;?)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;savedInstanceState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// Set where the player takes input frames from and where it displays the result&lt;/span&gt;
        &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CameraInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cameraDevice&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;surfaceOutput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;iOS, from the sample's &lt;code&gt;ViewController.swift&lt;/code&gt;. The output here is an &lt;code&gt;EffectPlayerView&lt;/code&gt; placed in the storyboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;UIKit&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;BNBSdkApi&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;BNBSdkCore&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;ViewController&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIViewController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Output surface for the Player&lt;/span&gt;
    &lt;span class="kd"&gt;@IBOutlet&lt;/span&gt; &lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;effectView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;EffectPlayerView&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;

    &lt;span class="c1"&gt;// Input stream for the Player&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;cameraDevice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CameraDevice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;cameraMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;FrontCameraSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;captureSessionPreset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hd1280x720&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Player processes frames from the input and renders them into the outputs&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;player&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Player&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;

    &lt;span class="c1"&gt;// Current effect&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;BNBEffect&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;viewDidLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;viewDidLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;player&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Player&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Camera&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;cameraDevice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cameraDevice&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;effectView&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;effect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"TrollGrandma"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;cameraDevice&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where this step fails: a black view. On Android, nothing is drawn until &lt;code&gt;cameraDevice.start()&lt;/code&gt; and &lt;code&gt;player.play()&lt;/code&gt; have both run, and in the sample those calls sit in the lifecycle methods shown in step 6, not in &lt;code&gt;onCreate&lt;/code&gt;. On iOS the sample starts the camera as the last line of &lt;code&gt;viewDidLoad&lt;/code&gt;, after &lt;code&gt;play()&lt;/code&gt; and after the effect load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Load an effect
&lt;/h2&gt;

&lt;p&gt;An effect is a folder. Per the &lt;a href="https://docs.banuba.com/far-sdk/effects/overview" rel="noopener noreferrer"&gt;effect structure&lt;/a&gt; docs, only &lt;code&gt;config.json&lt;/code&gt; is mandatory inside it, and the other files are images, shaders, videos and a &lt;code&gt;config.js&lt;/code&gt; script that the effect refers to. The samples ship an effect named &lt;code&gt;TrollGrandma&lt;/code&gt; in an &lt;code&gt;effects&lt;/code&gt; folder, and both platforms load it by name.&lt;/p&gt;

&lt;p&gt;Android loads asynchronously, and passing an empty string unloads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;companion&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;MASK_NAME&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"effects/TrollGrandma"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Inside onCreate, on a button:&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;shouldApply&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="n"&gt;showMaskButton&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setOnClickListener&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;shouldApply&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;shouldApply&lt;/span&gt;
    &lt;span class="n"&gt;showMaskButton&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shouldApply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="s"&gt;"Hide mask"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="s"&gt;"Show mask"&lt;/span&gt;
    &lt;span class="c1"&gt;// The mask is loaded asynchronously and applied&lt;/span&gt;
    &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shouldApply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;MASK_NAME&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;iOS loads synchronously with &lt;code&gt;player?.load(effect: "TrollGrandma")&lt;/code&gt;, as shown in step 4.&lt;/p&gt;

&lt;p&gt;Note the asymmetry in those two strings, because it is a common first bug. Android passes the path &lt;code&gt;effects/TrollGrandma&lt;/code&gt; relative to assets. iOS passes the bare name &lt;code&gt;TrollGrandma&lt;/code&gt;, because the &lt;code&gt;effects&lt;/code&gt; folder is already in the &lt;code&gt;resourcePath&lt;/code&gt; array you set in step 3. Copying the Android constant into the iOS project gives you a silent no-op.&lt;/p&gt;

&lt;p&gt;The decision at this step is where effects come from. You can write the folder yourself starting from a blank &lt;code&gt;config.json&lt;/code&gt;, build one in &lt;a href="https://studio.banuba.com/" rel="noopener noreferrer"&gt;Banuba Studio&lt;/a&gt;, or buy one from the &lt;a href="https://assetstore.banuba.net/" rel="noopener noreferrer"&gt;Banuba Asset Store&lt;/a&gt;. Which packages the effect needs comes back to step 2: a 3D mask needs face tracking only, while lipstick needs the &lt;code&gt;lips&lt;/code&gt; package.&lt;/p&gt;

&lt;p&gt;Where this step fails: the effect loads and nothing changes on screen, or part of it is missing. That is the missing-package symptom from step 2, and the logs name the package.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Handle permissions, the lifecycle and the device you did not test on
&lt;/h2&gt;

&lt;p&gt;The sample activity extends a &lt;code&gt;BaseActivity&lt;/code&gt; that requests &lt;code&gt;CAMERA&lt;/code&gt; and &lt;code&gt;RECORD_AUDIO&lt;/code&gt; at &lt;code&gt;onStart&lt;/code&gt; and finishes the activity if either is denied. Your app already has a permission flow, so the point is only that the camera device must not start before the grant.&lt;/p&gt;

&lt;p&gt;The lifecycle calls in the Android sample are the part most ports drop. Each object is started, paused and closed in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onStart&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onStart&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;// We start the camera and then the player starts taking frames&lt;/span&gt;
    &lt;span class="n"&gt;cameraDevice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onResume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onResume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onPause&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onPause&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;// Pause the player when the activity is inactive&lt;/span&gt;
    &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onStop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// After this, the camera stops capturing frames and transmitting them to the player&lt;/span&gt;
    &lt;span class="n"&gt;cameraDevice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onStop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onDestroy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// After you are done using the player, free all resources by calling close()&lt;/span&gt;
    &lt;span class="n"&gt;cameraDevice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;surfaceOutput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onDestroy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three &lt;code&gt;close()&lt;/code&gt; calls in &lt;code&gt;onDestroy&lt;/code&gt; are what the sample's own comment calls mandatory. A user who backgrounds the app without pressing anything goes through &lt;code&gt;onPause&lt;/code&gt; and &lt;code&gt;onStop&lt;/code&gt;, so the camera is released by that path too.&lt;/p&gt;

&lt;p&gt;The last decision is the device you test on. Banuba's &lt;a href="https://docs.banuba.com/far-sdk/tutorials/capabilities/technical_specification" rel="noopener noreferrer"&gt;technical specification&lt;/a&gt; publishes face tracking figures per device tier, measured under fixed lab conditions, and names no handsets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier, per Banuba's specification&lt;/th&gt;
&lt;th&gt;Single face, FPS&lt;/th&gt;
&lt;th&gt;Max angle, degrees&lt;/th&gt;
&lt;th&gt;Max distance, cm&lt;/th&gt;
&lt;th&gt;5 faces, FPS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Android Low&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;170&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android High&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS Mid&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;230&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS High&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;230&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Banuba produced these figures in its own lab, and no third party has repeated them. In text: single-face tracking holds 25 FPS on the Android Low tier and 30 FPS on Android High and both iOS tiers. The maximum tracking angle is 80 degrees on every tier. The maximum tracking distance is 170 cm on Android Low, 180 cm on Android High and 230 cm on both iOS tiers. With five faces in frame, Android Low drops to 22 FPS, Android High to 27, and both iOS tiers stay at 30. The specification lists five as the maximum number of faces tracked with acceptable quality on most devices.&lt;/p&gt;

&lt;p&gt;So the low-tier Android handset is the one that shows the gap between your first effect and the spec sheet. Run the effect you intend to ship on one before you decide the effect is done, and if you want a device list to start from, our tested guide to &lt;a href="https://www.banuba.com/blog/best-ar-sdk-for-android-and-ios" rel="noopener noreferrer"&gt;AR SDKs on Android and iOS&lt;/a&gt; names the handsets we used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six steps, two code files per platform, one device to test on
&lt;/h2&gt;

&lt;p&gt;The integration touches two code files per platform, plus your build files: an application class that initializes the SDK with the token, and a camera screen that connects a camera device to a &lt;code&gt;Player&lt;/code&gt; and an output view, then loads an effect by folder name.&lt;/p&gt;

&lt;p&gt;The costs sit at the edges. Token storage decides whether a renewal is a server change or a store submission. Package selection decides app size and whether a feature silently lacks its neural network. The lifecycle block decides whether the camera is released when the user leaves. And the Android Low tier, at 25 FPS single-face and 22 FPS with five faces in Banuba's own figures, is the device that tells you whether the effect is finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Where is the Banuba Face AR SDK documentation for Android and iOS integration?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Banuba publishes the integration docs per platform under one Getting Started page, with Android, iOS, Web, Flutter, React Native and Desktop tabs, and the installation page lists every Banuba Face AR SDK package with what it provides. The same docs ship an LLM-friendly &lt;code&gt;llms-full.txt&lt;/code&gt; file for AI-assisted setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where are the Banuba Face AR SDK code samples?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Banuba keeps platform-specific sample projects on GitHub. The Android and iOS repositories linked in this guide hold the camera sample used above, and Banuba also publishes quickstart repositories for Flutter, React Native, Web, Unity and desktop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens when the Banuba Face AR SDK token expires?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For one month after expiry, Banuba Face AR SDK keeps working but displays a Banuba watermark. After that month the SDK functionality stops while the rest of the app works normally. The token cannot be extended; you request a new one and update it in the app, which is why Banuba recommends storing it on a server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Banuba SDK watermark appeared after the trial, what do I do?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A watermark after the trial means the 14-day demo token of Banuba Face AR SDK has expired. Request a commercial token, or a renewed demo token if you have not finished evaluating, and replace the value you pass to &lt;code&gt;BanubaSdkManager.initialize&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Banuba Face AR SDK have Flutter and React Native plugins?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Banuba maintains the Banuba Face AR SDK Flutter plugin on pub.dev and the React Native plugin on npm, and both sit on the same SDK as the native Android and iOS integration shown here.&lt;/p&gt;

</description>
      <category>android</category>
      <category>ios</category>
      <category>tutorial</category>
      <category>augmentedreality</category>
    </item>
    <item>
      <title>6 Routes for Adding a Video Editing SDK to an iOS App and Where Each Breaks</title>
      <dc:creator>Team Banuba</dc:creator>
      <pubDate>Wed, 14 Oct 2026 08:37:14 +0000</pubDate>
      <link>https://dev.to/team_banuba/6-routes-for-adding-a-video-editing-sdk-to-an-ios-app-and-where-each-breaks-1053</link>
      <guid>https://dev.to/team_banuba/6-routes-for-adding-a-video-editing-sdk-to-an-ios-app-and-where-each-breaks-1053</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9jdfd5dhpjd9cgkj9j5r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9jdfd5dhpjd9cgkj9j5r.png" alt="Cover titled " width="799" height="499"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A video editing SDK for iOS is a packaged framework that hands your app a timeline, effects and export so your team does not assemble them from AVFoundation primitives. Six routes are realistic on iOS today, and the right one depends on a single input: the list of things your editor must do on day one.&lt;/p&gt;

&lt;p&gt;The routes fail at different points, from a portrait-only system view to a license that only arrives after a sales call. Banuba sells one of the SDKs compared below, the Banuba Video Editor SDK, and it gets the same treatment as the rest, including the jobs it cannot do and the toolchain it forces on your build machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: List what the editor has to do
&lt;/h2&gt;

&lt;p&gt;Before comparing anything, collect four inputs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your deployment floor and toolchain.&lt;/strong&gt; The minimum iOS version you ship and the Xcode version your CI runs. One SDK below requires Xcode 26.0, which in turn requires a recent macOS, so check your build machines before your deployment target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your feature tier.&lt;/strong&gt; Sort the plan into one of four buckets: (a) trim, join and export; (b) a timeline UI with text, music and filters; (c) camera-time effects such as face masks, beauty or background replacement applied while recording; (d) the same editor on Android, in a browser or on a server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your oldest supported device.&lt;/strong&gt; Step 5 times exports on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who signs contracts.&lt;/strong&gt; Two of the routes cannot be prototyped until someone talks to sales.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tier (a) has two no-cost answers; tiers (b) to (d) are where licensed SDKs come in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Choose a video editing SDK route (or none)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Product compared&lt;/th&gt;
&lt;th&gt;Access and availability&lt;/th&gt;
&lt;th&gt;iOS install path (per vendor docs)&lt;/th&gt;
&lt;th&gt;App size added (vendor-published)&lt;/th&gt;
&lt;th&gt;Trial (vendor terms)&lt;/th&gt;
&lt;th&gt;Where it breaks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UIVideoEditorController&lt;/td&gt;
&lt;td&gt;Apple's system trim view in UIKit&lt;/td&gt;
&lt;td&gt;Free, first-party&lt;/td&gt;
&lt;td&gt;Part of UIKit&lt;/td&gt;
&lt;td&gt;Ships with iOS&lt;/td&gt;
&lt;td&gt;Not applicable&lt;/td&gt;
&lt;td&gt;Portrait only; no subclassing or overlay views&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AVFoundation&lt;/td&gt;
&lt;td&gt;Apple's media framework&lt;/td&gt;
&lt;td&gt;Free, first-party&lt;/td&gt;
&lt;td&gt;Part of the iOS SDK&lt;/td&gt;
&lt;td&gt;Ships with iOS&lt;/td&gt;
&lt;td&gt;Not applicable&lt;/td&gt;
&lt;td&gt;No editor UI, no face AR, beauty, background removal or captions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Banuba Video Editor SDK&lt;/td&gt;
&lt;td&gt;Native editor with UI plus Face AR&lt;/td&gt;
&lt;td&gt;Open sample on GitHub; trial token on request, then license&lt;/td&gt;
&lt;td&gt;CocoaPods 1.12.1+ or SPM; iOS 15.0+, Swift 5.9+, Xcode 26.0+&lt;/td&gt;
&lt;td&gt;14 MB core, 45 MB full bundle&lt;/td&gt;
&lt;td&gt;14 days, all features&lt;/td&gt;
&lt;td&gt;Requires Xcode 26 on your build machines; no browser editor, no headless server render, no pro multi-track or HDR export&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IMG.LY Video Editor SDK (CE.SDK)&lt;/td&gt;
&lt;td&gt;Cross-platform editor engine&lt;/td&gt;
&lt;td&gt;Sales-gated custom quote&lt;/td&gt;
&lt;td&gt;CocoaPods, &lt;code&gt;pod 'IMGLYUI'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;td&gt;30 days, full access&lt;/td&gt;
&lt;td&gt;Each platform licensed separately; no face AR or beauty in documented features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meishe StreamingSDK + EffectSDK&lt;/td&gt;
&lt;td&gt;Pro editing and effects engine&lt;/td&gt;
&lt;td&gt;Login-gated download, contact sales&lt;/td&gt;
&lt;td&gt;Objective-C against &lt;code&gt;NvsStreamingContext&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;td&gt;Trial authorization, watermarked output&lt;/td&gt;
&lt;td&gt;Per-app license file bound to bundle ID; media must be transcoded to its own m3u8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Picsart Editor SDK&lt;/td&gt;
&lt;td&gt;Web editor embed&lt;/td&gt;
&lt;td&gt;CDN script tag; no iOS package&lt;/td&gt;
&lt;td&gt;None documented&lt;/td&gt;
&lt;td&gt;Not applicable&lt;/td&gt;
&lt;td&gt;Not evaluated&lt;/td&gt;
&lt;td&gt;Desktop browsers only; WebView unsupported&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Figures come from each vendor's own documentation as read in September 2026. Vendor pages change, so anything that decides a build (price, size, trial terms, license mechanics) should come from the vendor in writing.&lt;/p&gt;

&lt;h3&gt;
  
  
  UIVideoEditorController
&lt;/h3&gt;

&lt;p&gt;Apple's system interface for trimming and re-encoding a recorded movie, free and first-party. The &lt;a href="https://developer.apple.com/documentation/uikit/uivideoeditorcontroller" rel="noopener noreferrer"&gt;UIKit reference&lt;/a&gt; lists its limits: portrait mode only, no subclassing, a private view hierarchy, no overlay views, and availability only on devices that support video recording. It wins on effort, with zero UI code for trimming.&lt;/p&gt;

&lt;h3&gt;
  
  
  AVFoundation
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://developer.apple.com/documentation/avfoundation" rel="noopener noreferrer"&gt;AVFoundation&lt;/a&gt; is first-party, costs nothing and sits in the iOS SDK, with compositions, tracks and export sessions as building blocks. It wins on cost and dependency count. It has no editor UI and no face AR, beauty, background removal or automatic captions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Banuba Video Editor SDK
&lt;/h3&gt;

&lt;p&gt;A native iOS editor with its own UI, installed via CocoaPods or Swift Package Manager. The &lt;a href="https://www.banuba.com/video-editor-sdk" rel="noopener noreferrer"&gt;Video Editor SDK page&lt;/a&gt; lists transitions, an audio editor, text and GIFs, LUT color filters, templates, and Face AR with face filters and green screen background subtraction. Access starts with a 14-day trial token covering all features, then a license priced on monthly active users with no per-developer-seat fees and no public price list. Of the three licensed SDKs, it is the only one with a published size.&lt;/p&gt;

&lt;p&gt;Two boundaries worth knowing before you add the pod. It does not cover browser editing, headless server rendering, or pro multi-track and HDR work. And it requires Xcode 26.0 or newer, which is the strictest toolchain requirement of the six routes and the one most likely to stop a prototype on day one, since it rules out build machines on an older macOS.&lt;/p&gt;

&lt;h3&gt;
  
  
  IMG.LY Video Editor SDK
&lt;/h3&gt;

&lt;p&gt;Built on the CE.SDK engine, the &lt;a href="https://img.ly/products/video-sdk/" rel="noopener noreferrer"&gt;IMG.LY video SDK&lt;/a&gt; runs on iOS, Android, web, React Native, Flutter, Electron, macOS and Mac Catalyst, plus a Node.js headless renderer. It handles MP4, WebM and MOV, and IMG.LY states GDPR compliance and SOC 2 Type II certification. Access is a custom quote metered by MAU, with each platform licensed separately and AI plugin usage billed by the model providers. It wins on platform reach and on trial length. Face AR and beauty are absent from its documented features, and it does not publish an SDK size.&lt;/p&gt;

&lt;h3&gt;
  
  
  Meishe StreamingSDK
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://en.meishesdk.com/" rel="noopener noreferrer"&gt;Meishe&lt;/a&gt; pairs StreamingSDK with EffectSDK. It wins on editing depth: multi-track timelines, nestable sub-timelines, 4K editing, 8K image processing, HDR export with HLG or ST2084 curves, and 106- or 240-point face landmark models. Its access is the heaviest here: login-gated downloads, contact-sales pricing with no published figure, a per-app license bound to your bundle ID, a watermark until authorization, and 4K licensed separately. No SDK size is published.&lt;/p&gt;

&lt;h3&gt;
  
  
  Picsart Editor SDK
&lt;/h3&gt;

&lt;p&gt;A web embed loaded by a CDN script tag, for desktop browsers only. Mobile browsers are not officially supported and WebView is explicitly unsupported, which rules out wrapping it in an iOS app. It also lacks real-time editing, allows one source video per project, and caps output at 1080p and 10 minutes. We did not evaluate its signup terms because it has no iOS path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: How to trim and export a video with AVFoundation
&lt;/h2&gt;

&lt;p&gt;For tier (a), this is the whole job. The function loads a clip, clamps the end time to the asset's duration, exports the range as MP4 at the highest-quality preset, and throws on each failure path.&lt;/p&gt;

&lt;p&gt;Two export paths appear below, behind an availability check. The newer &lt;code&gt;export(to:as:)&lt;/code&gt; is cleaner, and the completion-handler path is what runs on an iOS 15 deployment target. Confirm the exact availability badge on &lt;a href="https://developer.apple.com/documentation/avfoundation/avassetexportsession" rel="noopener noreferrer"&gt;Apple's AVAssetExportSession reference&lt;/a&gt; against the SDK you build with, and delete the branch if your floor is above it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;AVFoundation&lt;/span&gt;

&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;TrimExportError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;sessionUnavailable&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;mp4NotSupported&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;exportFailed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;trimAndExport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;startSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;endSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="nv"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;asset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVURLAsset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CMTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;startSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;preferredTimescale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CMTimeMinimum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;CMTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;endSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;preferredTimescale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVAssetExportSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;presetName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AVAssetExportPresetHighestQuality&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;TrimExportError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessionUnavailable&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;supportedFileTypes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mp4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;TrimExportError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mp4NotSupported&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeRange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CMTimeRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Start from an empty destination path.&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;FileManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;#available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iOS&lt;/span&gt; &lt;span class="mf"&gt;18.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;export&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mp4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outputURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;
        &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outputFileType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mp4&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;withCheckedContinuation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exportAsynchronously&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;CancellationError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kt"&gt;TrimExportError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exportFailed&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Caller&lt;/span&gt;
&lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;trimAndExport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inputURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;outputURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Export finished: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;outputURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Export failed: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localizedDescription&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inputURL&lt;/code&gt; and &lt;code&gt;outputURL&lt;/code&gt; are your own file URLs.&lt;/p&gt;

&lt;h3&gt;
  
  
  The export session comes back nil
&lt;/h3&gt;

&lt;p&gt;The initializer is failable. Before blaming the file, call &lt;code&gt;AVAssetExportSession.determineCompatibility(ofExportPreset:with:outputFileType:completionHandler:)&lt;/code&gt; with your asset and preset; Apple provides it for this check.&lt;/p&gt;

&lt;h3&gt;
  
  
  The export throws on the MP4 file type
&lt;/h3&gt;

&lt;p&gt;Apple documents that the export throws when the file type is missing from &lt;code&gt;supportedFileTypes&lt;/code&gt;. The guard in the snippet reports that case as its own error.&lt;/p&gt;

&lt;h3&gt;
  
  
  The export throws after the user leaves the screen
&lt;/h3&gt;

&lt;p&gt;Apple's documentation says the export also ends on cancellation, and that cancelling the parent Task cancels it. If the task is tied to a view's lifetime, dismissing the view ends the export. The legacy branch surfaces the same case as a &lt;code&gt;CancellationError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Past this point, a timeline UI, filter previews and face effects during recording are yours to write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: How to integrate a video editor SDK for iOS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Banuba: install, permissions, license check
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://docs.banuba.com/ve-pe-sdk/docs/ios/requirements/" rel="noopener noreferrer"&gt;iOS requirements page&lt;/a&gt; sets the floor, and the &lt;a href="https://docs.banuba.com/ve-pe-sdk/docs/ios/integration-ve" rel="noopener noreferrer"&gt;integration guide&lt;/a&gt; walks through five tasks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the pods with CocoaPods 1.12.1 or newer, or the packages with Swift Package Manager.&lt;/li&gt;
&lt;li&gt;Add four usage strings to &lt;code&gt;Info.plist&lt;/code&gt;: &lt;code&gt;NSCameraUsageDescription&lt;/code&gt;, &lt;code&gt;NSMicrophoneUsageDescription&lt;/code&gt;, &lt;code&gt;NSPhotoLibraryUsageDescription&lt;/code&gt; and &lt;code&gt;NSAppleMusicUsageDescription&lt;/code&gt; (media library).&lt;/li&gt;
&lt;li&gt;Create a module class that builds a &lt;code&gt;VideoEditorConfig&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Create &lt;code&gt;BanubaVideoEditor&lt;/code&gt; with your license token.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;getLicenseState&lt;/code&gt; and open the editor only when it reports a valid license.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Check the Xcode version on your build machines before step 1. The requirements page lists Xcode 26.0 or newer, and a CI image or a developer laptop on an older macOS will not get there with a pod install.&lt;/p&gt;

&lt;p&gt;Banuba documents integration "in as little as 8 minutes"; that is our own claim, and the runnable &lt;a href="https://github.com/Banuba/ve-sdk-ios-integration-sample" rel="noopener noreferrer"&gt;ve-sdk-ios-integration-sample&lt;/a&gt; is how to test it on your machine. Teams using Claude Code, Codex or Qwen Code can install Banuba's &lt;a href="https://github.com/Banuba/ai-skills" rel="noopener noreferrer"&gt;agent skills&lt;/a&gt; and let the coding agent perform these steps.&lt;/p&gt;

&lt;h3&gt;
  
  
  BanubaVideoEditor returns nil
&lt;/h3&gt;

&lt;p&gt;Per the docs, the initializer returns nil when the token is incorrect, for example empty or truncated. Check how the token reaches the binary first.&lt;/p&gt;

&lt;h3&gt;
  
  
  The editor opens to a "Video content unavailable" screen
&lt;/h3&gt;

&lt;p&gt;The docs state this appears when the SDK starts with a revoked or expired license. The getLicenseState check in task 5 catches it before the user does.&lt;/p&gt;

&lt;h3&gt;
  
  
  IMG.LY: one pod, several licenses
&lt;/h3&gt;

&lt;p&gt;On iOS, IMG.LY installs with &lt;code&gt;pod 'IMGLYUI'&lt;/code&gt; via CocoaPods. The planning work is the license count, since iOS, Android, web and desktop are each licensed separately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Meishe: license file before first frame
&lt;/h3&gt;

&lt;p&gt;Meishe's iOS integration is written in Objective-C against &lt;code&gt;NvsStreamingContext&lt;/code&gt;. Download requires a login, and output stays watermarked until the per-app license, bound to your bundle ID, is authorized. Media must also go via Meishe's transcoding service into its proprietary m3u8 format before it can sit on a track, so plan that step into your import flow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Questions to ask about any client-side license token
&lt;/h3&gt;

&lt;p&gt;The license token ships inside your app, so ask every vendor, including us:&lt;/p&gt;

&lt;p&gt;How is the token scoped to the bundle ID, and what does the SDK do when the bundle ID differs?&lt;br&gt;
What happens if someone lifts the token from the binary and ships it in another app?&lt;br&gt;
How does revocation work, how fast does it reach installed apps, and what does the end user see?&lt;br&gt;
Does license validation need a network call, and what does the SDK do offline?&lt;/p&gt;

&lt;p&gt;Get the answers in writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Measure before you commit
&lt;/h2&gt;

&lt;p&gt;Three numbers settle most decisions, and each has to come from your own build.&lt;/p&gt;

&lt;h3&gt;
  
  
  Binary size delta
&lt;/h3&gt;

&lt;p&gt;Archive the app with and without the SDK and compare. Banuba's requirements docs publish 14 MB for the core editor and 45 MB for the full bundle. IMG.LY and Meishe do not publish a size, so the archive diff is your only number for them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Export wall-clock on your oldest device
&lt;/h3&gt;

&lt;p&gt;Time one fixed clip in each route, the AVFoundation snippet included, on the slowest phone you support.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trial window
&lt;/h3&gt;

&lt;p&gt;Banuba gives 14 days with all features, IMG.LY 30 days with full access, and Meishe a trial authorization with watermarked output. Plan the test so it fits the shortest window you are using.&lt;/p&gt;

&lt;p&gt;For more metrics, there is &lt;a href="https://helen-fedulova.hashnode.dev/7-video-editor-sdk-numbers-to-measure-in-your-own-app" rel="noopener noreferrer"&gt;a list of seven numbers worth measuring inside your own app&lt;/a&gt;. If engineering time is the open question, run &lt;a href="https://banuba.medium.com/ios-video-editor-sdk-cost-e32c0bd15fef" rel="noopener noreferrer"&gt;the build-versus-buy&lt;/a&gt; math next to these results.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose between the routes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If you are building a trim-and-share feature&lt;/strong&gt;, use AVFoundation, or UIVideoEditorController if a portrait system view is acceptable, because both cost nothing and add nothing to your download.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you are building a creator or social app where face effects run while recording&lt;/strong&gt;, use the Banuba Video Editor SDK, because the timeline UI and Face AR arrive in one dependency on iOS 15+; confirm your build machines can run Xcode 26 first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If your editor must also run on the web or render on a server&lt;/strong&gt;, use IMG.LY, because one engine covers web, mobile and a Node.js renderer; budget for one license per platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you need multi-track timelines, 4K editing or HDR export&lt;/strong&gt;, use Meishe, because its editing model goes deepest; start the license conversation early, since output stays watermarked until authorization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the editor lives only in a desktop browser&lt;/strong&gt;, Picsart's Editor SDK is the one that fits there; it is not an option for an iOS app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where each vendor ranks per criterion is covered in our &lt;a href="https://www.banuba.com/blog/best-ios-video-editor-sdks" rel="noopener noreferrer"&gt;iOS SDK comparison&lt;/a&gt; on the Banuba blog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stay on Apple's frameworks until effects or other platforms enter the plan
&lt;/h2&gt;

&lt;p&gt;Tier (a) apps can ship on AVFoundation with the snippet above and no license. Once camera-time effects, a timeline UI, web reach or HDR depth appear on the roadmap, a licensed SDK is the shorter path, and the scenario list names which one. Size and export time measured in your own app confirm it within the trial window.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What are the Banuba Video Editor SDK iOS requirements?
&lt;/h3&gt;

&lt;p&gt;iOS 15.0 or newer, Swift 5.9 or newer and Xcode 26.0 or newer, installed through CocoaPods 1.12.1+ or Swift Package Manager. Banuba supports arm64 and arm64e, with limited x86-64 support. The Xcode floor is the one to check first, because it constrains your build machines rather than your users' phones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a free way to add video editing to an iOS app?
&lt;/h3&gt;

&lt;p&gt;Yes. AVFoundation and UIVideoEditorController cost nothing. Among licensed options, Banuba offers a 14-day trial with all features, IMG.LY 30 days with full access, and Meishe a watermarked trial authorization.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can the Banuba Video Editor SDK run in a browser or on a server?
&lt;/h3&gt;

&lt;p&gt;No. Banuba covers native iOS and Android plus Flutter and React Native. For browser editing or headless rendering, IMG.LY's CE.SDK is the documented fit.&lt;/p&gt;

&lt;h3&gt;
  
  
  How is the Banuba Video Editor SDK priced compared with IMG.LY and Meishe?
&lt;/h3&gt;

&lt;p&gt;Banuba prices on monthly active users with no per-developer-seat fees and no public price list. IMG.LY quotes per customer, metered by MAU, with each platform licensed separately. Meishe publishes no price and routes buyers to sales.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>aivideo</category>
      <category>agentskills</category>
    </item>
    <item>
      <title>Virtual Eyewear Try-On: 4 Jobs Between Frame Photos and Cart</title>
      <dc:creator>Team Banuba</dc:creator>
      <pubDate>Mon, 31 Aug 2026 20:44:28 +0000</pubDate>
      <link>https://dev.to/team_banuba/virtual-eyewear-try-on-4-jobs-between-frame-photos-and-cart-8ep</link>
      <guid>https://dev.to/team_banuba/virtual-eyewear-try-on-4-jobs-between-frame-photos-and-cart-8ep</guid>
      <description>&lt;p&gt;Putting eyewear try-on on a storefront breaks into four jobs: turning photographs of each frame into a try-on asset, deciding which browsers get the entry point, mounting the widget and choosing what opens it, and connecting the catalog check to the cart events. Three are front-end work. The first is a data pipeline, and it sets the launch date.&lt;/p&gt;

&lt;p&gt;The ticket usually arrives with a design attached, a button under the add-to-cart block, and no picture of what sits behind it. The code below uses the &lt;a href="https://www.banuba.com/glasses-virtual-try-on" rel="noopener noreferrer"&gt;Banuba virtual try-on widget&lt;/a&gt; because that is what we build, so the attribute and method names are ours, and every fact about them is cited to our own documentation. The four jobs hold for any web try-on engine you mount.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;p&gt;Four things belong in hand before the first commit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An HTTPS origin for every environment the widget runs in, staging included.&lt;/li&gt;
&lt;li&gt;A merchant ID issued by Banuba, which the widget uses to load your configuration.&lt;/li&gt;
&lt;li&gt;Photographs of every frame in the launch collection, shot to the digitization spec rather than pulled from the web catalog.&lt;/li&gt;
&lt;li&gt;A decision about where the entry point lives on the product detail page, since it drives the gating logic in the second job.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Digitizing the frames
&lt;/h2&gt;

&lt;p&gt;Digitization turns photographs of a physical frame into the asset the widget renders on a face, and in the admin dashboard you do it one frame at a time. Per frame you supply a name, a brand picked from the list or created on the spot, a SKU, an optional product page URL, then three images in the try-on model step: left, body and right. A preview renders while the images upload, so a bad set shows itself before you save, &lt;a href="https://tintvto.com/docs/glasses-digitization.html" rel="noopener noreferrer"&gt;as our glasses dashboard guide describes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Since widget v1.7.0, released 30 April 2026 according to &lt;a href="https://tintvto.com/docs/changelog.html" rel="noopener noreferrer"&gt;our widget changelog&lt;/a&gt;, that processing runs automatically. The consequence lands on whoever owns the photography. Our docs state that photos failing the requirements give a poor result, and that it cannot be repaired afterwards, because nothing about the processing is manual. Treat the photo spec as an input requirement with the same weight as a schema.&lt;/p&gt;

&lt;p&gt;Here is the part that reshapes a project plan. The bulk CSV path is makeup only. In &lt;a href="https://tintvto.com/docs/bulk_upload.html" rel="noopener noreferrer"&gt;our bulk upload template&lt;/a&gt; the Category column always takes the value MAKEUP, and the columns after it are Region, Finish, Color as a 6-digit hex, and Coverage, all parameters of a cosmetic product. Frames therefore go in through the Glasses tab individually, and our docs mark that tab Beta. For a catalog of any size that is a data entry line with a person's name on it, and sizing it comes before anyone commits to a launch date.&lt;/p&gt;

&lt;p&gt;Production work asks for more than the three try-on images. The commercial list in &lt;a href="https://tintvto.com/docs/knowledge-base.html" rel="noopener noreferrer"&gt;our knowledge base&lt;/a&gt; adds UPC number, a storefront image link, the product page URL, photos from multiple angles, and a 3D model, with frame size where it is known. Collect all of it in the same pass, since walking the collection twice costs the same hours twice.&lt;/p&gt;

&lt;p&gt;The decision point is ownership. Someone has to supply the photographs, either merchandising from existing shoots or a studio from a new one, and that answer moves the timeline more than any code in this article does. Pull one frame record out of your PIM as well and see whether the frame size fields exist there at all.&lt;/p&gt;

&lt;p&gt;What goes wrong here is scheduling. A team scopes the work as a front-end sprint, then finds in week one that the launch collection has to be entered by hand and reshot on top, because the existing photography was styled for hero banners at an angle the try-on step cannot use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deciding which browsers get the button
&lt;/h2&gt;

&lt;p&gt;The try-on button should render off a runtime check rather than a static support table, because camera access has hard requirements a shopper's browser either satisfies or fails. The first is transport. Webcam access needs an HTTPS/TLS connection, so a staging box on plain HTTP fails at the first call and looks exactly like a broken widget; MDN documents &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia" rel="noopener noreferrer"&gt;the secure context rule behind getUserMedia&lt;/a&gt;, which applies to every camera API on the page, ours included.&lt;/p&gt;

&lt;p&gt;The second is the engine. &lt;a href="https://tintvto.com/docs/introduction.html" rel="noopener noreferrer"&gt;Our documented system requirements&lt;/a&gt; specify the last 2 Safari major versions, the last 2 iOS major versions, and the last 5 versions of Chrome, Opera, Edge and Samsung Internet. Read that as the set we test against. A version floor describes a population, while the shopper in front of you is one device with one driver and one browser build.&lt;/p&gt;

&lt;p&gt;The answer for an individual shopper comes from feature detection at render time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cameraIsAvailable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSecureContext&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getUserMedia&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tryOnButton&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open-vto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;tryOnButton&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;cameraIsAvailable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;window.isSecureContext covers transport, navigator.mediaDevices is absent entirely on browsers without media capture, and tryOnButton is the entry point your PDP template renders. The button now appears only where both conditions hold.&lt;/p&gt;

&lt;p&gt;Then comes the branch, and both sides are defensible. Hiding the entry point on a failing check keeps the page honest, at the cost of a shopper who never learns the feature exists. Rendering it with a static model photo behind it keeps the feature discoverable. Eyewear weakens that second option in a way a cosmetic shade does not: a frame on a stock model shows how the style reads, while the question that made the shopper tap was how it sits on their own face.&lt;/p&gt;

&lt;p&gt;Skip the gate and the failure is uniform. Every shopper sees the button, and the ones on an old in-app browser or an HTTP staging link get a modal that opens onto nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mounting the widget and picking the trigger
&lt;/h2&gt;

&lt;p&gt;Mounting is two lines of markup, and the judgment call here is what opens the widget afterwards. The bundle loads from a CDN, and the element goes wherever the experience should appear, both covered in &lt;a href="https://tintvto.com/docs/integration.html" rel="noopener noreferrer"&gt;our integration guide&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://tintvto.com/widget.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That path carries no version segment, so it resolves to the current release. Confirm a pinned URL with your technical manager before production, because a minor release can change behavior on a page you have not deployed to in months.&lt;/p&gt;

&lt;p&gt;On a product detail page you want the widget focused on the frame being viewed, which is what the isolated SKU attributes do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;tint-vto&lt;/span&gt; &lt;span class="na"&gt;merchant-id=&lt;/span&gt;&lt;span class="s"&gt;"YOUR_MERCHANT_ID"&lt;/span&gt; &lt;span class="na"&gt;isolated-sku&lt;/span&gt; &lt;span class="na"&gt;sku=&lt;/span&gt;&lt;span class="s"&gt;"8028997081552"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/tint-vto&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"open-vto"&lt;/span&gt; &lt;span class="na"&gt;data-sku=&lt;/span&gt;&lt;span class="s"&gt;"8028997081552"&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Try these on&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;merchant-id is the identifier Banuba issues you, isolated-sku switches the widget into single product mode, and sku names the frame on this page. The token and sdk-token attributes are documented as deprecated and slated for removal, so check any snippet inherited from an older sample.&lt;/p&gt;

&lt;p&gt;That merchant ID ships in your page source and any visitor can read it in devtools, which is normal for a widget that loads its configuration client side and is also the first question a security reviewer will ask. The answer worth having in writing before launch is how the identifier is scoped: which origins it works from, how that list is configured, and what happens when someone copies it onto their own domain. Put those three questions to your technical manager during evaluation rather than the week before release.&lt;/p&gt;

&lt;p&gt;Our integration guide documents launch triggers for opening immediately, opening on the window load event, and opening on a customer action. A product detail page wants the third, so that the camera permission prompt follows an explicit tap and reads as an answer to something the shopper asked for.&lt;/p&gt;

&lt;p&gt;The camera itself can be declined, and the fallback is two method calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;module&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;widget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tint-vto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tryOnButton&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open-vto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;tryOnButton&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useWebcam&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modelPhoto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/media/model-front.webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modelPhoto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/script&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;useWebcam() resolves with the active stream, or with null when the shopper denies access, and useImage() accepts a Blob or File to render on instead. usePhoto() is a deprecated alias of useImage(). On a frames PDP that fallback stays a courtesy, for the fit reason above.&lt;/p&gt;

&lt;p&gt;Two things break here in practice. One is the inherited snippet still passing token, which keeps working until the release that removes it. The other is a widget wired to open on page load, which spends the camera prompt before the shopper has expressed any interest and trains them to dismiss it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting the catalog and the cart
&lt;/h2&gt;

&lt;p&gt;Two wires finish the integration: a catalog check deciding whether the button renders for this frame, and the events keeping the cart in step with the widget. The check is isCustomerSkuExist(customerSkuIds: string[]), which resolves true when every SKU you pass exists in the try-on catalog and can be called before open(), per &lt;a href="https://tintvto.com/docs/widget-api.html" rel="noopener noreferrer"&gt;our widget API reference&lt;/a&gt;. Put it in the PDP render path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;module&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;widget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tint-vto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tryOnButton&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open-vto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentSku&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tryOnButton&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isDigitized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isCustomerSkuExist&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;currentSku&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nx"&gt;tryOnButton&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isDigitized&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;cameraIsAvailable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/script&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;currentSku reads the data-sku attribute your template wrote onto the button, and cameraIsAvailable() is the detection function from the previous job, imported from wherever you keep it. A frame with no try-on asset yet never shows a button. &lt;/p&gt;

&lt;p&gt;That gate carries more weight for eyewear than for a cosmetic shade, where a missing asset still leaves a swatch and a product photo to look at.&lt;/p&gt;

&lt;p&gt;Cart sync runs on the events the widget emits. Attach the listeners once, wherever you mount the element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storefrontCart&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// your own cart module&lt;/span&gt;

&lt;span class="nx"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;addToCart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;removeFromCart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redirectToCart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/cart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our API reference documents detail as an array of Product objects on the first two events, and as null on redirectToCart, which fires when the shopper clicks Go to cart. To drive the widget from your own product data instead, applyProduct() takes fully defined product objects and applyProductByCustomerSku() takes your customer facing SKU identifiers.&lt;/p&gt;

&lt;p&gt;For device checks, widget v1.7.1 of 18 May 2026 added try-on links and QR codes generated per product or for a whole catalog, per the same changelog. QA becomes scanning a code: open it on the phones your analytics say people shop from, and walk the flow inside the Instagram and Facebook in-app browsers, with no deploy behind any of it. Rendering and camera behavior diverge across handsets much further than a support matrix suggests, which is what &lt;a href="https://www.banuba.com/blog/best-web-ar-apis-tested" rel="noopener noreferrer"&gt;our web AR testing kept running into&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The defects to watch for are quiet ones. Leave any of those events unhandled and the cart drifts, so a shopper adds a frame inside the try-on and finds the header count unchanged. Ship a frame to the PDP with a live button before anyone has processed its photos and you get the ungated version of the check above.&lt;/p&gt;

&lt;h2&gt;
  
  
  What carries over to another engine
&lt;/h2&gt;

&lt;p&gt;Swap the vendor and the identifiers change while the shapes survive. There is always an asset preparation step with a photography spec attached, and it is the long pole. A capability gate follows it, since camera access on the web is conditional everywhere. Then a mount point plus a trigger decision, where the case for the tap is the permission prompt. Last comes the boundary between the try-on surface and your cart, crossed by events one way and product identifiers the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;The three front-end jobs are a normal week for someone holding the merchant ID and a PDP template. The variable is the photography, and better code will not compress it, because it is bounded by how fast someone shoots frames to spec and enters them one at a time through a Beta tab. So run the estimate in that order: count the frames in the launch collection, find out who is shooting them and whether the UPC and frame size fields exist in your PIM today, and put the code work after that number.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What do you upload for each frame?
&lt;/h3&gt;

&lt;p&gt;A name, a brand, a SKU, an optional product page URL, and three images of the frame: left, body and right. Going to production, collect the commercial set in the same pass, which adds UPC number, a storefront image link, photos from multiple angles, a 3D model, and frame size where you have it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can frames be bulk imported from a CSV?
&lt;/h3&gt;

&lt;p&gt;No. The bulk upload template is makeup only: its Category field takes the single value MAKEUP and its parameter columns are Region, Finish, Color and Coverage. Frames go in individually through the Glasses tab of the admin dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does the try-on work locally but not on my staging server?
&lt;/h3&gt;

&lt;p&gt;Almost always because staging is served over plain HTTP. Browsers restrict camera access to secure contexts, and localhost counts as one while &lt;a href="http://staging.example.com" rel="noopener noreferrer"&gt;http://staging.example.com&lt;/a&gt; does not. Put a certificate on staging and the same build works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does eyewear try-on need a native app?
&lt;/h3&gt;

&lt;p&gt;No. The widget is web based, loads from a CDN script tag, and runs in the browser on desktop and mobile, so no app store release sits in the critical path.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens on an unsupported browser?
&lt;/h3&gt;

&lt;p&gt;The widget opens and the camera call fails, which reads to the shopper as a broken page. Hence the feature detection in the second job: the entry point renders only where a secure context and a media devices API are both present.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which attributes should a new integration use?
&lt;/h3&gt;

&lt;p&gt;merchant-id, plus isolated-sku and sku on a product detail page. token and sdk-token are deprecated and scheduled for removal, and usePhoto() is a deprecated alias of useImage().&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>ecommerce</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>6 AR Makeup Try-On Failures a React Storefront Hits in Production</title>
      <dc:creator>Team Banuba</dc:creator>
      <pubDate>Wed, 19 Aug 2026 09:37:09 +0000</pubDate>
      <link>https://dev.to/team_banuba/6-ar-makeup-try-on-failures-a-react-storefront-hits-in-production-1e07</link>
      <guid>https://dev.to/team_banuba/6-ar-makeup-try-on-failures-a-react-storefront-hits-in-production-1e07</guid>
      <description>&lt;p&gt;AR makeup try-on rarely fails in the demo. It fails on the third page load, on a mid-range Android, in the Instagram in-app browser, or on a skin tone nobody on the team has. Six failure modes account for most of it: camera permission handling, double-mounted effects, shade fidelity, frame rate collapse, SKU mapping drift, and mobile browser policy.&lt;/p&gt;

&lt;p&gt;Each one below has the same shape: what actually breaks, why React or the mobile browser makes it worse, and the test you can run before a shopper finds it. &lt;/p&gt;

&lt;p&gt;The code examples use the &lt;a href="https://tintvto.com/docs/introduction.html" rel="noopener noreferrer"&gt;Banuba virtual try-on widget&lt;/a&gt; because that is what we build, but every test method here is vendor-neutral. If you are integrating a different engine, the checks still apply.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc36x1dvz5zhvy6cb6e8i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc36x1dvz5zhvy6cb6e8i.png" alt="banuba makeup virtual try-on widget preview on the frontend" width="799" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a try-on demo pass and the storefront fail?
&lt;/h2&gt;

&lt;p&gt;A vendor demo runs one product, one browser, one lighting setup, and one face. A storefront runs your whole catalog across every device your analytics has ever seen. The gap between those two is where the six failures live.&lt;/p&gt;

&lt;p&gt;It is worth being precise about what a web try-on actually is before testing it. It is a camera stream, a face tracking model, a rendering layer, and a mapping from your SKUs to render parameters. Three of the six failures below are in code you wrote, not in the engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What happens when the shopper denies camera access?
&lt;/h2&gt;

&lt;p&gt;This is the most common one and the cheapest to fix. getUserMedia can be denied, dismissed, blocked by enterprise policy, or unavailable because the page is not on a secure origin. Per MDN, getUserMedia is only available in a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia" rel="noopener noreferrer"&gt;secure context&lt;/a&gt;, meaning HTTPS or localhost, so a staging box on plain HTTP will fail every time and look like a bug in the widget.&lt;/p&gt;

&lt;p&gt;A try-on with no fallback shows a black rectangle and the shopper leaves the page. A try-on with a fallback shows the product on a model photo and keeps the session alive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async function openTryOn(widget) {
  await widget.open();

 const stream = await widget.useWebcam();
  if (!stream) {
    // Camera denied, dismissed, or unavailable. Do not leave the modal empty.
    const model = await fetch("/model.webp").then((r) =&amp;gt; r.blob());
    await widget.useImage(model);
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How to test it
&lt;/h3&gt;

&lt;p&gt;In Chrome DevTools, open the three-dot menu, go to More tools, then Sensors, and block camera permission for the origin. Then repeat on plain HTTP. Both paths must render something. Track the denial rate as an event, because if a third of your sessions never get a camera, the fallback image is your real try-on experience and deserves the same design attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Why does the camera light stay on after the modal closes?
&lt;/h2&gt;

&lt;p&gt;This one is specific to React and it is the failure most teams ship without noticing. React 18 in development deliberately mounts, unmounts, and remounts every component. The React documentation is blunt about the reason: Strict Mode calls effects twice so that "bugs caused by missing cleanup logic" surface in development instead of production.&lt;/p&gt;

&lt;p&gt;If your effect opens the widget and never releases it, you get two widget instances and two camera streams. In development the symptom is a doubled modal. &lt;/p&gt;

&lt;p&gt;In production the symptom is worse: the camera indicator stays lit after the shopper closes the try-on, which reads as spyware and generates support tickets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { useEffect, useRef } from "react";
export function TryOn({ merchantId, sku }) {
  const ref = useRef(null);

  useEffect(() =&amp;gt; {
    const widget = ref.current;
    let cancelled = false;

    (async () =&amp;gt; {
      await widget.open();
      if (cancelled) await widget.close();
    })();

    return () =&amp;gt; {
      cancelled = true;
      widget.close();          // release the stream
    };
  }, [sku]);

  return &amp;lt;tint-vto ref={ref} merchant-id={merchantId} isolated-sku sku={sku} /&amp;gt;;
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How to test it
&lt;/h3&gt;

&lt;p&gt;Open the try-on, close it, and watch the browser tab's camera indicator. It must go dark within a second. Then navigate between two product pages five times and confirm you still have exactly one widget instance in the DOM. Run this with Strict Mode on, not off. Turning Strict Mode off to make the double mount go away hides the bug rather than fixing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Does the shade look right on every skin tone, or only on yours?
&lt;/h2&gt;

&lt;p&gt;This is the failure that costs money rather than support tickets, and it is the hardest to catch because the team testing it is usually not diverse enough to catch it. &lt;/p&gt;

&lt;p&gt;The single most common complaint reported by virtual try-on buyers is filed, almost word for word, as "product color not displayed correctly": the shade on screen does not match the shade in the box. A naive color overlay washes out on deep skin and looks artificial on light skin, so the same lipstick reads as two different products depending on who is looking at it.&lt;/p&gt;

&lt;p&gt;The fix is not something you test by eye once. Build a fixed test matrix and run it every release. The dermatology standard for this is the Fitzpatrick scale, which defines six skin phototypes, and it gives you a defensible set of rows rather than an arbitrary one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwtqmnc4iluvjnsbub0da.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwtqmnc4iluvjnsbub0da.jpg" alt="the rable comparing test axis (skin tone, lighting, finish, capture) versus minimum coverage" width="629" height="208"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Written out rather than only in the table: cover all six Fitzpatrick phototypes, four lighting conditions including backlit, at least four finishes including one high-coverage foundation, and both the live webcam path and the uploaded photo path. Twenty-four to forty-eight screenshots per release is a small price against a shade that only works on half your customers.&lt;/p&gt;

&lt;p&gt;On the engine side, skin-tone-aware rendering is the specific capability that separates a try-on from a filter. The &lt;a href="https://www.banuba.com/tint-makeup-virtual-try-on" rel="noopener noreferrer"&gt;Banuba virtual try-on widget&lt;/a&gt; applies 16 makeup product types with skin-tone-aware application for exactly this reason, and supports 16 or more product categories including eyewear, hair color, contacts, jewelry, and accessories. &lt;/p&gt;

&lt;p&gt;Whichever engine you pick, ask for its output across your matrix before you sign, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. What is your frame rate on the cheapest phone in your analytics?
&lt;/h2&gt;

&lt;p&gt;Real-time face tracking plus rendering is a per-frame GPU and CPU budget. On a flagship phone you will never see the ceiling. On a three-year-old midrange Android the frame rate drops, the render lags behind the face by a few frames, and the try-on stops feeling like a mirror.&lt;/p&gt;

&lt;p&gt;Pick a floor and enforce it. A practical one is 24 frames per second sustained over sixty seconds, since that is where motion stops reading as stuttering to most people. &lt;/p&gt;

&lt;p&gt;Measure sustained rather than peak, because thermal throttling is what actually gets you: the first ten seconds look fine and second thirty do not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let frames = 0;
const start = performance.now();

function tick() {
  frames += 1;
  if (performance.now() - start &amp;lt; 60000) requestAnimationFrame(tick);
  else console.log("sustained fps:", frames / 60);
}
requestAnimationFrame(tick);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How to test it
&lt;/h3&gt;

&lt;p&gt;run it on a real device, not the emulator, and not immediately after a charge. Pull the three most common device models out of your analytics and test the slowest one. If you only have flagships in the office, a cheap prepaid Android is the highest-value QA hardware you can buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Why does one SKU render as a generic tint?
&lt;/h2&gt;

&lt;p&gt;Try-on engines do not render your product. They render the parameters someone mapped to your product: a region, a color, a finish, a coverage level. &lt;/p&gt;

&lt;p&gt;When merchandising adds a shade and nobody digitizes it, the try-on either shows nothing or falls back to something approximate, and the shopper sees a color that is not the one on the box.&lt;/p&gt;

&lt;p&gt;This is a data integrity problem, so treat it as one. Add a contract test in CI that walks your live catalog and asserts every purchasable SKU exists on the try-on side.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Fails the build when merchandising adds a SKU that nobody digitized.
const skus = await getPurchasableSkus();          // your catalog
const ok = await widget.isCustomerSkuExist(skus); // try-on catalog
if (!ok) throw new Error("SKU present in catalog but missing from try-on");
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That isCustomerSkuExist call is from the Banuba widget API, and most engines expose an equivalent lookup; the &lt;a href="https://github.com/Banuba/beauty-web" rel="noopener noreferrer"&gt;open web sample&lt;/a&gt; has a minimal page you can diff your own wiring against.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to test it
&lt;/h3&gt;

&lt;p&gt;Run that check nightly against production data, not just on the fixture set in your test suite. Fixtures never drift. Catalogs always do. Then decide the product behavior for a missing SKU: hiding the try-on button is almost always better than opening a try-on that shows the wrong color.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Does it work inside the Instagram browser?
&lt;/h2&gt;

&lt;p&gt;A large share of beauty traffic arrives from social, which means the page opens in an in-app webview rather than Safari or Chrome. &lt;/p&gt;

&lt;p&gt;Those webviews have their own camera permission behavior, and iOS has a long-standing quirk that catches teams out: WebKit requires the playsinline attribute on a video element, otherwise the stream takes over the whole screen in fullscreen playback instead of rendering in place.&lt;/p&gt;

&lt;p&gt;If you embed the try-on in your own native app rather than the mobile web, the same constraint applies from the other direction. Camera access inside a webview needs a real HTTPS origin, so loading inline HTML without setting a proper base URL will fail even though the identical markup works in a browser tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Android WebView: JS on, and a real HTTPS base URL or the camera never opens.
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.loadDataWithBaseURL("https://shop.example.com/", html, "text/html", "UTF-8", null)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How to test it
&lt;/h3&gt;

&lt;p&gt;Send yourself the product URL through Instagram, Facebook, and TikTok on both iOS and Android, and open it from inside each app. Do not test by pasting the link into Safari. That is a different browser with different rules, and it is not where your traffic is.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does a release checklist look like?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pulling the six together, the pre-release pass is short:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Camera denied and plain-HTTP origin both render a fallback, not a black box.&lt;/li&gt;
&lt;li&gt;Camera indicator goes dark within a second of closing, with Strict Mode on.&lt;/li&gt;
&lt;li&gt;Shade matrix screenshots taken across six phototypes and four lighting setups.&lt;/li&gt;
&lt;li&gt;Sustained 24 fps over sixty seconds on the slowest device in your top three.&lt;/li&gt;
&lt;li&gt;Nightly SKU contract test green against production catalog data.&lt;/li&gt;
&lt;li&gt;Product URL opened from inside Instagram on iOS and Android.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the integration itself, the reference worth having open alongside the checklist is the &lt;a href="https://tintvto.com/docs/integration.html" rel="noopener noreferrer"&gt;widget integration guide&lt;/a&gt;, which documents the element, its attributes, and the lifecycle methods used above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;The engineering work in AR makeup try-on has moved. Face tracking and rendering are the vendor's problem now, and a first web integration is realistically a two-week front-end task rather than a computer vision project. &lt;/p&gt;

&lt;p&gt;What has not moved is everything around it: permissions, component lifecycle, device spread, catalog integrity, and the browsers your customers actually use.&lt;/p&gt;

&lt;p&gt;Those six are yours regardless of which engine you integrate, and they are the difference between a try-on that converts and one that quietly loses sessions. Build the test matrix before the launch, not after the first support ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need a native app to run AR makeup try-on?
&lt;/h3&gt;

&lt;p&gt;No. A web-based try-on loads as a script bundle and a custom HTML element on your existing product page, so it runs in the mobile browser without a separate app. A native app can embed the same widget through a webview when you want it inside an existing app experience.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does the try-on work on desktop but not on my staging server?
&lt;/h3&gt;

&lt;p&gt;Almost always the secure context rule. Browsers only expose getUserMedia over HTTPS or localhost, so a staging box served over plain HTTP will never get a camera stream no matter how correct the integration is.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test shade accuracy without a diverse test team?
&lt;/h3&gt;

&lt;p&gt;Use a fixed matrix instead of ad hoc checks. Six Fitzpatrick phototypes against four lighting conditions gives you 24 screenshots per finish, which you can collect from a stock photo set for the upload path and from contractors or colleagues for the live path. The point is that it is repeatable between releases.&lt;/p&gt;

&lt;h3&gt;
  
  
  What frame rate is good enough?
&lt;/h3&gt;

&lt;p&gt;Set a floor rather than chasing a number. Sustained 24 fps over a full minute on your slowest common device is a reasonable bar, because motion below that starts reading as stutter and thermal throttling shows up after the first few seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can one integration cover more than makeup?
&lt;/h3&gt;

&lt;p&gt;Yes, if the engine supports it. The Banuba widget covers 16 or more product categories including eyewear, hair color, contacts, jewelry, and accessories, so a storefront that integrates for lipstick can enable additional categories later through configuration rather than a second integration.&lt;/p&gt;

&lt;h3&gt;
  
  
  What breaks most often after launch?
&lt;/h3&gt;

&lt;p&gt;Catalog drift. The integration is static once it ships, but merchandising keeps adding SKUs, and any SKU that is not digitized on the try-on side either disappears or renders approximately. A nightly contract test catches it before a customer does.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
