<?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: hariharan</title>
    <description>The latest articles on DEV Community by hariharan (@hariharanjagan).</description>
    <link>https://dev.to/hariharanjagan</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%2F3986129%2Fdfab0f3d-bee7-4b5a-bf2f-93e81fde0d94.jpg</url>
      <title>DEV Community: hariharan</title>
      <link>https://dev.to/hariharanjagan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hariharanjagan"/>
    <language>en</language>
    <item>
      <title>What's New in Apple's Foundation Models Framework at WWDC 2026</title>
      <dc:creator>hariharan</dc:creator>
      <pubDate>Thu, 18 Jun 2026 12:21:00 +0000</pubDate>
      <link>https://dev.to/hariharanjagan/whats-new-in-apples-foundation-models-framework-at-wwdc-2026-5227</link>
      <guid>https://dev.to/hariharanjagan/whats-new-in-apples-foundation-models-framework-at-wwdc-2026-5227</guid>
      <description>&lt;h2&gt;
  
  
  From a single on-device model to a hybrid, multimodal, agentic AI platform — and it's going open source
&lt;/h2&gt;

&lt;p&gt;When Apple shipped the Foundation Models framework at WWDC 2025, the pitch was tidy: a Swift API onto the ~3B-parameter model that powers Apple Intelligence, running on-device, no API key, no network round-trip, no per-token cost. Great for privacy-sensitive, offline-friendly work — and deliberately narrow.&lt;/p&gt;

&lt;p&gt;WWDC 2026 blows that scope wide open. The framework is no longer "the on-device model with a nice Swift wrapper." It's now a hybrid platform that spans on-device inference, Apple's own server models on Private Cloud Compute, and third-party frontier models like Claude and Gemini — all behind one session API. It gained vision. It gained agentic primitives. It got a Python SDK, a CLI, an evaluations framework, and it's going open source this summer, including on Linux.&lt;/p&gt;

&lt;p&gt;This is a tour of what changed, with the Swift that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The on-device model got rebuilt
&lt;/h2&gt;

&lt;p&gt;The headline isn't a new feature — it's that the base model is better. Apple rebuilt the on-device model with stronger reasoning and tool calling, and refined the guardrails to cut false positives (a real pain point in the first release, where benign prompts sometimes tripped safety filters).&lt;/p&gt;

&lt;p&gt;Two practical APIs landed (starting in iOS 26.4) that you'll reach for constantly: inspecting context size and counting tokens before you spend them.&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="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SystemLanguageModel&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="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contextSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// 8192&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;count&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;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tokenCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"What are the Japanese characters for origami?"&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="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The on-device context window is 8,192 tokens. Knowing your token budget up front means you can decide — programmatically — whether a request fits on-device or needs to escalate to a server model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vision: the on-device model can now see
&lt;/h2&gt;

&lt;p&gt;The on-device model is multimodal now. You attach an image to a prompt and ask about it — object identification, text extraction, screenshot understanding, receipt parsing — entirely on-device, no cloud hop.&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="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;response&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;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;respond&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"What animal is this?"&lt;/span&gt;
    &lt;span class="kt"&gt;Attachment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UIImage&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attachments accept &lt;code&gt;UIImage&lt;/code&gt;, &lt;code&gt;NSImage&lt;/code&gt;, &lt;code&gt;CGImage&lt;/code&gt;, Core Image, &lt;code&gt;CVPixelBuffer&lt;/code&gt;, and file URLs, at any size. Larger images simply cost more tokens, so it's worth downsampling when you don't need full resolution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Private Cloud Compute: Apple's server models, same API
&lt;/h2&gt;

&lt;p&gt;For tasks the small on-device model can't handle, you can now route to Apple's larger server models running on Private Cloud Compute (PCC) — through the same &lt;code&gt;LanguageModelSession&lt;/code&gt;.&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="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;PrivateCloudComputeLanguageModel&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;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;LanguageModelSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The selling points: a 32K context window, configurable reasoning levels, and crucially &lt;strong&gt;no account setup, no auth, no API keys&lt;/strong&gt; — it inherits the same privacy guarantees as the rest of Apple Intelligence. As of this release it's available on watchOS 27 too.&lt;/p&gt;

&lt;p&gt;The business news that made this real for most teams: at the Platforms State of the Union, Apple announced &lt;strong&gt;free PCC access for developers with fewer than two million first-time App Store downloads&lt;/strong&gt;. That removes the infrastructure cost barrier that would otherwise make cloud inference a non-starter for indie and mid-size apps.&lt;/p&gt;

&lt;p&gt;You can inspect exactly what a request consumed, including cached and reasoning tokens:&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="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;response&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;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respond&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="s"&gt;"Recommend a craft that doesn't require scissors."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;contextOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ContextOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;reasoningLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;light&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;totalTokenCount&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="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cachedTokenCount&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="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;totalTokenCount&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="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reasoningTokenCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The model abstraction layer — and partner models
&lt;/h2&gt;

&lt;p&gt;This is the architectural centerpiece. A new &lt;code&gt;LanguageModel&lt;/code&gt; protocol lets any model — local or server — back a &lt;code&gt;LanguageModelSession&lt;/code&gt;. Apple's own models already conform, and the framework ships open-source &lt;code&gt;CoreAILanguageModel&lt;/code&gt; and &lt;code&gt;MLXLanguageModel&lt;/code&gt; conformers for running local models on the Neural Engine and GPU.&lt;/p&gt;

&lt;p&gt;The downstream consequence: &lt;strong&gt;swapping models is a Swift Package Manager change, not a code change.&lt;/strong&gt; Anthropic and Google publish Swift packages for their frontier models that conform to the same protocol. Your session logic, your prompts, your tool definitions — all unchanged.&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="c1"&gt;// On-device&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;LanguageModelSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SystemLanguageModel&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;// Apple server model&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;LanguageModelSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;PrivateCloudComputeLanguageModel&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;// Third-party frontier model — same session API&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;LanguageModelSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ClaudeModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&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 partner packages handle auth and billing securely (OAuth, credentials in Keychain) and expose per-token usage including cache and reasoning tokens. The model becomes a configuration choice — prototype on the free on-device model, escalate to Claude or Gemini for the hard queries, ship without rewriting your inference layer.&lt;/p&gt;

&lt;p&gt;One constraint worth flagging: you still can't bring your own fine-tuned weights to the on-device runtime. If you need a domain-specific model, that's Core AI's territory, not Foundation Models.&lt;/p&gt;

&lt;h2&gt;
  
  
  System tools: Vision and Spotlight, built in
&lt;/h2&gt;

&lt;p&gt;The framework now ships tools the model can call directly. Two are Vision-backed — &lt;code&gt;BarcodeReaderTool&lt;/code&gt; and &lt;code&gt;OCRTool&lt;/code&gt; — letting the model reason over visual information without you wiring up Vision yourself.&lt;/p&gt;

&lt;p&gt;The more interesting one is a Spotlight-powered search tool that enables &lt;strong&gt;fully local Retrieval-Augmented Generation&lt;/strong&gt;. Queries route through Spotlight first, then the results feed into the model's context. This replaces the custom vector-database infrastructure teams were standing up for on-device search — for a lot of use cases, Spotlight already had the index.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dynamic Profiles: the agentic primitive
&lt;/h2&gt;

&lt;p&gt;This is the big new building block for agentic apps. A &lt;code&gt;DynamicProfile&lt;/code&gt; is a declarative description of a session's instructions and tools — and a single session can swap between profiles as the app's mode changes, while preserving conversation history.&lt;/p&gt;

&lt;p&gt;The WWDC sample is a crafts app that flips between a "craft analysis" mode and a "brainstorm" mode. You describe each profile as a struct:&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;struct&lt;/span&gt; &lt;span class="kt"&gt;CraftProfile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;LanguageModelSession&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;DynamicProfile&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;DynamicProfile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;Profile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;Instructions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="s"&gt;"""
                You are an expert crafting assistant. &lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;
                Record craft project image analyses   &lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;
                using the recordImageAnalysis tool.
                """&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="kt"&gt;RecordImageAnalysisTool&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;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;LanguageModelSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CraftProfile&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here's where it gets powerful — you can vary the &lt;em&gt;model and reasoning level per profile branch&lt;/em&gt;. Quick analysis stays on the fast on-device model; the open-ended brainstorm escalates to Private Cloud Compute with deep reasoning — all within one profile, history intact:&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;struct&lt;/span&gt; &lt;span class="kt"&gt;CraftProfile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;LanguageModelSession&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;DynamicProfile&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;states&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CraftProjectStates&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;DynamicProfile&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;states&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&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;craftAnalysis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="kt"&gt;Profile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;Instructions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="kt"&gt;RecordImageAnalysisTool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="kt"&gt;SwitchModeTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;states&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;states&lt;/span&gt;&lt;span class="p"&gt;)&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;brainstorm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="kt"&gt;Profile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;Instructions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="kt"&gt;BrainstormRecordTool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;states&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;privateCloudCompute&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reasoningLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deep&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;A profile resolves to exactly one active branch at a time. The model itself can drive the transition by calling a tool — here a &lt;code&gt;SwitchModeTool&lt;/code&gt; that mutates app state, which the framework observes to rebuild the session:&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;struct&lt;/span&gt; &lt;span class="kt"&gt;SwitchModeTool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Tool&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;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Switch to a different mode."&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;states&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AppStates&lt;/span&gt;

    &lt;span class="kd"&gt;@Generable&lt;/span&gt;
    &lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;Arguments&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;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Mode&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;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Arguments&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;PromptRepresentable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;appStates&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"Successfully switched to &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the cleanest expression yet of multi-tool, multi-mode agent behavior on Apple platforms — declarative, type-safe, and with model selection as a first-class part of the design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluations: measuring quality, not vibes
&lt;/h2&gt;

&lt;p&gt;Anyone who's shipped an LLM feature knows the trap: you tweak a prompt, it feels better, you ship, and you have no idea if you actually improved anything or just got lucky on three test prompts. Apple's new Evaluations framework is a Swift framework for quantifying accuracy as you iterate, so you can understand the statistical impact of a change before shipping. This is the boring-but-essential piece that makes the rest production-viable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tooling: an fm CLI and a Python SDK
&lt;/h2&gt;

&lt;p&gt;Two additions that broaden where these models live.&lt;/p&gt;

&lt;p&gt;In macOS 27, an &lt;code&gt;fm&lt;/code&gt; command-line tool brings on-device and PCC models to the terminal — &lt;code&gt;fm chat&lt;/code&gt; for interactive use, or pipe text through it in shell scripts to summarize, extract, or generate.&lt;/p&gt;

&lt;p&gt;And there's a &lt;strong&gt;Python SDK&lt;/strong&gt; exposing the same on-device model as the Swift framework, aimed at data scientists and researchers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;apple_fm_sdk&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;fm&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SystemLanguageModel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;is_available&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_available&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_available&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;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LanguageModelSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello!&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Open source — and it runs on Linux
&lt;/h2&gt;

&lt;p&gt;The quiet bombshell: the Foundation Models framework is &lt;strong&gt;going open source this summer&lt;/strong&gt;, and because it's Swift, it runs wherever Swift runs — including Linux servers. Alongside it, Apple is shipping a Foundation Models framework utilities package, updated between OS releases, with emerging building blocks: transcript management, a skill API, and a chat-completions interface for interop.&lt;/p&gt;

&lt;p&gt;For a framework that started life as strictly on-device, Apple-platform-only, this is a remarkable reframing. The session API you write against on iPhone is, increasingly, a portable abstraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for how you build
&lt;/h2&gt;

&lt;p&gt;Put it together and the recommended pattern for an app shipping in the iOS 27 cycle is a deliberate hybrid:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use the &lt;strong&gt;on-device model&lt;/strong&gt; for fast, free, offline, privacy-sensitive work — now with vision and better reasoning.&lt;/li&gt;
&lt;li&gt;Escalate to &lt;strong&gt;Private Cloud Compute&lt;/strong&gt; when you need a bigger context window or deeper reasoning, free under two million downloads, with the same privacy posture.&lt;/li&gt;
&lt;li&gt;Route to &lt;strong&gt;Claude or Gemini&lt;/strong&gt; for the genuinely hard queries — same session code, swapped via SPM.&lt;/li&gt;
&lt;li&gt;Compose it all with &lt;strong&gt;Dynamic Profiles&lt;/strong&gt;, and validate it with the &lt;strong&gt;Evaluations framework&lt;/strong&gt; before you ship.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model is no longer a fixed dependency baked into your architecture — it's a configuration choice you can change without touching your logic. That's the real story of WWDC 2026: not any single model, but the abstraction layer that finally makes model choice a runtime decision.&lt;/p&gt;

&lt;p&gt;A couple of caveats to keep on your radar: there's still no bring-your-own-fine-tune path for the on-device runtime (that's Core AI), and Siri AI features won't be available in the EU or China at iOS 27 launch — EU developers can't even test them during development — which matters if your user base spans those regions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where to start
&lt;/h3&gt;

&lt;p&gt;Download the Xcode 27 beta, run &lt;code&gt;fm chat&lt;/code&gt; in Terminal to feel out the on-device model, then pull Apple's sample app and get hands-on with Dynamic Profiles and the Evaluations framework. The deep-dive sessions on PCC, evaluations, the Xcode instrument, and dynamic profiles are where the real detail lives.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>ai</category>
      <category>wwdc</category>
    </item>
    <item>
      <title>Bridging RTSP to WebRTC on iOS: Feeding GStreamer Frames into a WebRTC Pipeline</title>
      <dc:creator>hariharan</dc:creator>
      <pubDate>Mon, 15 Jun 2026 19:14:00 +0000</pubDate>
      <link>https://dev.to/hariharanjagan/bridging-rtsp-to-webrtc-on-ios-feeding-gstreamer-frames-into-a-webrtc-pipeline-5bm1</link>
      <guid>https://dev.to/hariharanjagan/bridging-rtsp-to-webrtc-on-ios-feeding-gstreamer-frames-into-a-webrtc-pipeline-5bm1</guid>
      <description>&lt;p&gt;Most WebRTC tutorials on iOS assume your video comes from the device camera. You call &lt;code&gt;RTCCameraVideoCapturer&lt;/code&gt;, point it at the front lens, and the framework handles the rest. But a large class of real applications doesn't have a camera as the source at all — it has an IP camera, a dashcam, a drone feed, or some other device speaking RTSP, and the iOS app's job is to ingest that stream and republish it over WebRTC for low-latency delivery (often into a managed SFU like Amazon Kinesis Video Streams).&lt;/p&gt;

&lt;p&gt;That problem turns out to be more interesting than it looks, because the two halves of the system have fundamentally different ideas about where pixels come from. RTSP gives you an encoded elementary stream pulled off the network. WebRTC's iOS SDK wants to &lt;em&gt;own&lt;/em&gt; the capture path and hand you an encoder. Getting one to feed the other means stepping outside the comfortable parts of both frameworks and meeting in the middle — at the level of raw I420 frame buffers.&lt;/p&gt;

&lt;p&gt;This article walks through a working architecture for exactly that bridge: RTSP ingestion and decoding via GStreamer, conversion to raw YUV, and injection into the WebRTC pipeline through a custom capturer. The signaling and ICE negotiation are handled against Kinesis Video Streams, but the core technique is provider-agnostic and applies to any libwebrtc-based stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you can't just point WebRTC at an RTSP URL&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The WebRTC native SDK has no concept of a network video source. Its capture abstraction, &lt;code&gt;RTCVideoCapturer&lt;/code&gt;, is designed around the assumption that frames originate locally and synchronously — a camera, the screen, a file. The &lt;code&gt;RTCCameraVideoCapturer&lt;/code&gt; subclass that most apps use wires &lt;code&gt;AVCaptureSession&lt;/code&gt; output straight into a video source. There is no built-in "open this RTSP URL" path, and there's a good reason for that: RTSP involves session setup, RTP depayloading, jitter buffering, and decoding, none of which belongs inside a real-time media engine whose entire design goal is to push locally captured frames out the door as fast as possible.&lt;/p&gt;

&lt;p&gt;So the ingestion side has to be solved separately, and GStreamer is the pragmatic choice. It has mature RTSP support, hardware-accelerated decoders where available, and a clean way to extract decoded frames from the end of a pipeline via &lt;code&gt;appsink&lt;/code&gt;. The architecture becomes:&lt;/p&gt;

&lt;p&gt;RTSP camera ──▶ GStreamer (depay + decode + convert) ──▶ appsink&lt;br&gt;
                                                           │&lt;br&gt;
                                                  raw I420 frames&lt;br&gt;
                                                           │&lt;br&gt;
                                                           ▼&lt;br&gt;
                              custom RTCVideoCapturer ──▶ WebRTC ──▶ SFU/peer&lt;/p&gt;

&lt;p&gt;GStreamer handles everything up to and including "I now have a decoded YUV frame in memory." WebRTC handles everything from "here is a frame, encode and transmit it." The bridge between them is the part you have to build, and it's where most of the subtlety lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The GStreamer pipeline&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The ingestion pipeline is constructed with &lt;code&gt;gst_parse_launch&lt;/code&gt;, which lets you describe the whole chain as a string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rtspsrc &lt;span class="nv"&gt;location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rtsp://&amp;lt;host&amp;gt;/&amp;lt;path&amp;gt; &lt;span class="o"&gt;!&lt;/span&gt;
rtph264depay &lt;span class="o"&gt;!&lt;/span&gt; avdec_h264 &lt;span class="o"&gt;!&lt;/span&gt; videoconvert &lt;span class="o"&gt;!&lt;/span&gt; videoscale &lt;span class="o"&gt;!&lt;/span&gt;
video/x-raw, &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;I420, &lt;span class="nv"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;640, &lt;span class="nv"&gt;height&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;480 &lt;span class="o"&gt;!&lt;/span&gt;
appsink &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mysink emit-signals&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true sync&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false &lt;/span&gt;max-buffers&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;drop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading left to right: &lt;code&gt;rtspsrc&lt;/code&gt; handles RTSP session negotiation and produces RTP packets. &lt;code&gt;rtph264depay&lt;/code&gt; extracts the H.264 elementary stream from RTP. &lt;code&gt;avdec_h264&lt;/code&gt; decodes it to raw frames. &lt;code&gt;videoconvert&lt;/code&gt; and &lt;code&gt;videoscale&lt;/code&gt; normalize the pixel format and dimensions, and the explicit &lt;code&gt;video/x-raw, format=I420&lt;/code&gt; caps filter forces the output into planar YUV 4:2:0 — which, not coincidentally, is exactly what WebRTC's &lt;code&gt;RTCI420Buffer&lt;/code&gt; expects. Pinning the resolution at this stage (here 640×480) means the downstream copy logic can assume fixed plane sizes rather than re-reading caps on every frame.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;appsink&lt;/code&gt; properties are where the real-time character of the system gets enforced, and they deserve individual attention because the defaults are wrong for this use case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sync=false&lt;/code&gt;&lt;/strong&gt; tells the sink not to throttle to the pipeline clock. For playback you &lt;em&gt;want&lt;/em&gt; clock sync so video plays at the correct speed; for a live bridge you want frames out the instant they're decoded, because the WebRTC layer downstream is doing its own pacing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max-buffers=1&lt;/code&gt;&lt;/strong&gt; caps the internal queue at a single frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;drop=true&lt;/code&gt;&lt;/strong&gt; says that when that one-frame buffer is full, throw away the oldest frame rather than blocking the pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together, &lt;code&gt;max-buffers=1 drop=true&lt;/code&gt; implement a "latest frame wins" policy. This is the single most important latency decision in the whole ingestion path. If you let frames queue up, any momentary stall downstream — a slow encode, a main-thread hiccup — turns into a growing backlog, and the stream drifts further and further behind real time with no way to recover. By dropping rather than queuing, you trade the occasional skipped frame for a hard ceiling on accumulated latency. For a live monitoring or dashcam scenario, that's exactly the right trade: nobody wants a perfectly smooth video that's eight seconds stale.&lt;/p&gt;

&lt;h3&gt;
  
  
  Threading and the GLib main loop
&lt;/h3&gt;

&lt;p&gt;One non-obvious requirement: GStreamer's bus and signal machinery need a running GLib main loop, and that loop blocks. You cannot run it on the main thread of an iOS app. The pipeline is therefore set up and run inside a dispatched background block, with its own &lt;code&gt;GMainContext&lt;/code&gt; pushed as the thread-default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g_main_context_new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;g_main_context_push_thread_default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ... build pipeline, attach bus watch ...&lt;/span&gt;
&lt;span class="n"&gt;main_loop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g_main_loop_new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FALSE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;g_main_loop_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;main_loop&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// blocks here until quit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creating a private &lt;code&gt;GMainContext&lt;/code&gt; rather than using the default one matters in a mixed app: it keeps this pipeline's event sources isolated, so multiple pipelines (or other GLib-using code) don't fight over a shared context. Teardown then runs in the reverse order — quit the loop, pop and unref the context, set the pipeline to &lt;code&gt;GST_STATE_NULL&lt;/code&gt;, and unref it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A note on dynamic pads
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;rtspsrc&lt;/code&gt; doesn't expose its source pads until it has connected to the server and discovered the stream's contents, so you can't statically link it at construction time. If you build the pipeline element-by-element rather than with &lt;code&gt;gst_parse_launch&lt;/code&gt;, you have to connect to its &lt;code&gt;pad-added&lt;/code&gt; signal and do the linking in the callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;on_rtsp_pad_added&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GstElement&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GstPad&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;new_pad&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gpointer&lt;/span&gt; &lt;span class="n"&gt;user_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;GstElement&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;depay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GST_ELEMENT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;GstPad&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sink_pad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gst_element_get_static_pad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;depay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sink"&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="n"&gt;gst_pad_is_linked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sink_pad&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;gst_pad_link&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_pad&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sink_pad&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;gst_object_unref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sink_pad&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;gst_parse_launch&lt;/code&gt; hides this for you — it handles the deferred linking internally — which is one reason the string form is worth preferring for a fixed pipeline. The manual approach is only worth the extra code when you need runtime control over individual elements.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bridge: pulling frames out of GStreamer
&lt;/h2&gt;

&lt;p&gt;Every decoded frame triggers the &lt;code&gt;appsink&lt;/code&gt;'s &lt;code&gt;new-sample&lt;/code&gt; signal, wired to a C callback. The callback's job is to get the frame data out of GStreamer's memory and onto iOS's main queue for handoff to WebRTC, and the lifetime management here is where it's easy to introduce a crash or a subtle corruption bug.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="n"&gt;objc&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;GstFlowReturn&lt;/span&gt; &lt;span class="nf"&gt;on_new_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GstAppSink&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gpointer&lt;/span&gt; &lt;span class="n"&gt;user_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;GStreamerBackend&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__bridge&lt;/span&gt; &lt;span class="n"&gt;GStreamerBackend&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;user_data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;GstSample&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sample&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gst_app_sink_pull_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sink&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="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;GST_FLOW_ERROR&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;GstBuffer&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gst_sample_get_buffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;GstMapInfo&lt;/span&gt; &lt;span class="n"&gt;map&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;gst_buffer_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GST_MAP_READ&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;copiedData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;malloc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&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;copiedData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;memcpy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;copiedData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;dataSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;NSData&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;frameData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;NSData&lt;/span&gt; &lt;span class="nf"&gt;dataWithBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;copiedData&lt;/span&gt; &lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;dataSize&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="n"&gt;dispatch_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dispatch_get_main_queue&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delegate&lt;/span&gt; &lt;span class="nf"&gt;processRawNSDataFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;frameData&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
                &lt;span class="n"&gt;free&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;copiedData&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="n"&gt;gst_buffer_unmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;gst_sample_unref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;GST_FLOW_OK&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 critical detail is the defensive copy. The &lt;code&gt;GstBuffer&lt;/code&gt; you get from &lt;code&gt;gst_app_sink_pull_sample&lt;/code&gt; is only valid until you unref the sample, and the mapped memory is only valid between &lt;code&gt;gst_buffer_map&lt;/code&gt; and &lt;code&gt;gst_buffer_unmap&lt;/code&gt;. But the handoff to WebRTC happens asynchronously on the main queue — by the time that block runs, the sample is long gone. You cannot hand the mapped pointer across a &lt;code&gt;dispatch_async&lt;/code&gt; boundary. So the frame bytes are &lt;code&gt;memcpy&lt;/code&gt;'d into a heap allocation that the dispatched block owns and frees, and the GStreamer buffer is unmapped and unreffed synchronously before the callback returns.&lt;/p&gt;

&lt;p&gt;This copy is the one unavoidable allocation per frame in the path. It's tempting to try to eliminate it, but the asynchronous boundary makes it necessary unless you redesign around a frame pool — and at 640×480 I420 the copy is a fraction of a megabyte, cheap relative to the decode that produced it. (If you do find this allocation showing up in profiling under high frame rates, a fixed-size ring of preallocated buffers is the standard answer, but measure before reaching for it.)&lt;/p&gt;

&lt;p&gt;Crossing onto the main queue is a deliberate choice rather than an accident: it gives a single, predictable thread on which frames enter the WebRTC layer, which sidesteps a class of data races around the capturer's delegate. It does couple frame delivery to main-thread responsiveness, which is the cost side of that decision — if your UI thread stalls, frame injection stalls with it. For an app whose main job &lt;em&gt;is&lt;/em&gt; displaying this video, that coupling is usually acceptable, but it's worth knowing it's there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The injection side: a synthetic capturer
&lt;/h2&gt;

&lt;p&gt;On the WebRTC side, the trick is to create a video source and a &lt;em&gt;bare&lt;/em&gt; &lt;code&gt;RTCVideoCapturer&lt;/code&gt; — not the camera subclass — and then feed it frames manually. The capturer normally has a real implementation behind it that produces frames; here it's essentially a handle whose only purpose is to carry the source's delegate.&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;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;createVideoTrack&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;RTCVideoTrack&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;videoSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;WebRTCClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;videoSource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;videoSource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;adaptOutputFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;toWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;720&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;videoCapturer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;RTCVideoCapturer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;delegate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;videoSource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;WebRTCClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;videoTrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;videoSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;trackId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"KvsVideoTrack"&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 video source &lt;em&gt;is&lt;/em&gt; the capturer's delegate. That's the hook. Anything delivered to &lt;code&gt;videoCapturer.delegate&lt;/code&gt; as an &lt;code&gt;RTCVideoFrame&lt;/code&gt; enters the WebRTC encode-and-transmit path exactly as if a camera had produced it. So frame injection becomes a matter of building an &lt;code&gt;RTCVideoFrame&lt;/code&gt; from the raw bytes and calling the delegate:&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;func&lt;/span&gt; &lt;span class="nf"&gt;processRawNSDataFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NSData&lt;/span&gt;&lt;span class="p"&gt;)&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;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;640&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;480&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;i420Buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;RTCMutableI420Buffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;height&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;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assumingMemoryBound&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="kt"&gt;UInt8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&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;ySize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;uSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ySize&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;

    &lt;span class="nf"&gt;memcpy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i420Buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mutableDataY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ySize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;memcpy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i420Buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mutableDataU&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ySize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;memcpy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i420Buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mutableDataV&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ySize&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;uSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uSize&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;timestampNs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;NSEC_PER_SEC&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;rtcFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;RTCVideoFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i420Buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;timeStampNs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timestampNs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;videoCapturer&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delegate&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capturer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;videoCapturer&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;didCapture&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rtcFrame&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 I420 memory layout is the thing to get right. A 4:2:0 planar frame stores all the luma (Y) samples first — one per pixel — followed by the two chroma planes (U then V) at quarter resolution, since chroma is subsampled both horizontally and vertically. So for a W×H frame, Y is &lt;code&gt;W*H&lt;/code&gt; bytes, and U and V are each &lt;code&gt;W*H/4&lt;/code&gt; bytes. The three &lt;code&gt;memcpy&lt;/code&gt; calls split the flat buffer GStreamer produced back into the three plane pointers WebRTC's &lt;code&gt;RTCMutableI420Buffer&lt;/code&gt; exposes. Because the GStreamer caps filter already guaranteed I420 at this exact resolution, the offsets are fixed and known; this is the payoff for pinning the format upstream.&lt;/p&gt;

&lt;p&gt;A guard on the incoming length is worth adding before any of the &lt;code&gt;memcpy&lt;/code&gt;s — a frame that's shorter than &lt;code&gt;W*H*3/2&lt;/code&gt; means something upstream changed format or truncated, and copying plane data out of a short buffer reads past the end:&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="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;else&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;"Frame too small — unexpected format or truncation"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A word on timestamps
&lt;/h3&gt;

&lt;p&gt;The frame timestamp here is derived from wall-clock time at the moment of injection (&lt;code&gt;Date().timeIntervalSince1970&lt;/code&gt;). That's the simplest thing that works, and for a live bridge where every frame is "now" it's defensible. But it discards the timing information GStreamer actually had — the PTS on the original buffer — and uses arrival time as a proxy for capture time. If the ingestion path ever introduces variable delay, monotonic-but-uneven wall-clock stamps can confuse the encoder's rate control and the far end's jitter buffer. A more robust version propagates the GStreamer buffer PTS through the bridge and converts it into the WebRTC timebase. For a first working system, wall-clock is fine; it's a known place to tighten up later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signaling and connection management
&lt;/h2&gt;

&lt;p&gt;The media bridge is the novel part, but a working system still needs signaling and ICE, and a couple of details in that layer are worth calling out because they bite people.&lt;/p&gt;

&lt;p&gt;The connection is established against Kinesis Video Streams, which means resolving a signaling channel ARN (creating the channel if it doesn't exist), fetching the WSS and HTTPS endpoints, signing the WebSocket URL with SigV4 using Cognito credentials, gathering ICE servers, and finally opening the signaling socket. The offer/answer and ICE candidate exchange then follow the standard WebRTC handshake over that socket.&lt;/p&gt;

&lt;p&gt;The detail that catches people is &lt;strong&gt;ICE candidate ordering&lt;/strong&gt;. Candidates can arrive over the signaling channel before the SDP offer/answer exchange has completed — that is, before there's a peer connection ready to accept them. Adding a candidate to a peer connection that hasn't had its remote description set yet is an error. The fix is a pending-candidate queue: if no peer connection exists for a given client ID yet, candidates get held in a per-client set; once the SDP exchange completes and the connection is established, the queue is drained in order.&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;func&lt;/span&gt; &lt;span class="nf"&gt;checkAndAddIceCandidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;remoteCandidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;RTCIceCandidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;peerConnectionFoundMap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// SDP exchange not done yet — hold the candidate&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pendingIceCandidatesMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;RTCIceCandidate&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remoteCandidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;pendingIceCandidatesMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pending&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="c1"&gt;// connection is up — add directly&lt;/span&gt;
        &lt;span class="n"&gt;peerConnectionFoundMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remoteCandidate&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;This is the same problem every WebRTC implementation eventually solves; it just shows up early here because the signaling and media paths run at different speeds. Keying the queue by client ID means the same logic handles a session with multiple remote peers without them stepping on each other.&lt;/p&gt;

&lt;p&gt;The peer connection configuration is otherwise conventional, with a few choices worth noting: &lt;code&gt;unifiedPlan&lt;/code&gt; semantics (the modern default and required for newer SDKs), &lt;code&gt;gatherContinually&lt;/code&gt; so candidates keep flowing as network conditions change, &lt;code&gt;maxBundle&lt;/code&gt; to multiplex all media over a single transport, and &lt;code&gt;tcpCandidatePolicy = .enabled&lt;/code&gt; so the connection can fall back to TCP candidates when UDP is blocked — relevant for cameras or clients behind restrictive networks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this architecture buys you, and what it costs
&lt;/h2&gt;

&lt;p&gt;The strength of this design is separation of concerns. GStreamer is genuinely good at network ingest and decode, with broad codec and transport support you'd spend months reimplementing. WebRTC is genuinely good at adaptive, low-latency, NAT-traversing real-time transport. By meeting them at the raw-frame boundary, each does what it's best at, and the bridge between them is small enough to reason about completely.&lt;/p&gt;

&lt;p&gt;The costs are real and worth stating plainly. There's a full decode-then-re-encode cycle: GStreamer decodes H.264 to raw YUV, and WebRTC re-encodes that YUV (typically to VP8/VP9/H.264 again) for transport. That's CPU and battery you wouldn't spend if you could forward the encoded stream directly — but forwarding encoded RTSP straight into WebRTC isn't generally possible, because WebRTC controls its own encoder for congestion-responsive bitrate adaptation. The per-frame copies (one out of GStreamer, three into the I420 planes) add allocation pressure that matters at high resolutions or frame rates. And the main-queue handoff couples media flow to UI responsiveness.&lt;/p&gt;

&lt;p&gt;For the live-monitoring and dashcam-style use cases this targets — where the alternative is no low-latency path at all — those costs are the price of admission, and they're manageable. Where they'd start to hurt is high-resolution, high-frame-rate, multi-stream scenarios on battery-constrained hardware; that's the point at which you'd want to profile the copy and re-encode overhead seriously and consider a frame pool, PTS propagation, and moving the injection off the main queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency: where it actually comes from
&lt;/h2&gt;

&lt;p&gt;A note on performance claims, since this is the question everyone asks. Glass-to-glass latency in this system is the sum of several stages: RTSP/RTP transport and jitter buffering, H.264 decode, the format conversion and copy, the main-queue hop, WebRTC encode, network transport to the far end, and finally far-end decode and render. The &lt;code&gt;max-buffers=1 drop=true&lt;/code&gt; policy bounds the &lt;em&gt;accumulated&lt;/em&gt; latency on the ingest side, and &lt;code&gt;sync=false&lt;/code&gt; removes playback-clock delay, but the absolute number depends heavily on the camera's encoder GOP structure, network conditions, and the far-end client.&lt;/p&gt;

&lt;p&gt;I'm not going to quote a figure I haven't measured on a fixed setup, because a latency number without a stated measurement method is noise. If you're reporting this, measure end-to-end with a known method — a millisecond clock or timestamped pattern visible in-frame, captured at both ends — and report the conditions alongside the number. &lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The reusable insight here is the frame-level bridge pattern: when two media frameworks each insist on owning their end of the pipeline, you can often join them at the raw-buffer boundary by creating a bare &lt;code&gt;RTCVideoCapturer&lt;/code&gt; and driving its delegate yourself. The same technique works for screen capture, generated/synthetic video, ML-processed frames, or any source WebRTC doesn't natively support — anywhere you can produce an &lt;code&gt;RTCVideoFrame&lt;/code&gt;, you can inject into WebRTC. The RTSP case is just the most common reason to need it.&lt;/p&gt;

&lt;p&gt;The parts that took the most care weren't the parts the tutorials cover. They were the buffer lifetime across the async boundary, the I420 plane arithmetic, the appsink queue policy, and the ICE candidate ordering — the unglamorous edges where two systems with different assumptions actually have to agree.&lt;/p&gt;

</description>
      <category>rtps</category>
      <category>ios</category>
      <category>webrtc</category>
      <category>gstreamer</category>
    </item>
  </channel>
</rss>
