Supporting custom media formats and decoders
At a glance
| Item | Summary |
|---|---|
| Purpose | Extend the media formats the system can open. A Mac app embeds two MediaExtension app extensions: a format reader for the custom “Simple Container Format” (.simpl) and a video decoder for a run-length codec (XRLE). The app plays any movie and exports movies in either format. |
| App architecture | Three targets. A SwiftUI + Objective-C host app (FormatReaderAndDecoder) and two ExtensionKit extensions (SimpleContainerReader, RLEVideoDecoder) embedded in the app. Each extension’s EXPrincipalClass is a factory that conforms to a MediaExtension protocol and creates per-file or per-stream worker objects. The extensions never call each other or the app. They share only C header contracts in Shared/. |
| Main patterns | Factory (principal-class factories, plus subclass selection by media type), Delegate or data-source callbacks (RLEMovieExporterDelegate, DropDelegate), Binding-based state propagation (save-panel modifier) |
| Project style | One folder per target, plus Shared/ for headers used across targets. The host app keeps its Objective-C export pipeline in RLEMovieExporter/ and exposes it to Swift through a bridging header. |
| Execution model | App: Swift 5 mode with no default actor isolation. It uses Task.detached for blocking registration, a plain Task for export, and Task { @MainActor in } hops for UI state. The exporter uses a serial dispatch queue, a dispatch group, semaphores, and a C11 atomic cancel flag. Extensions: synchronous completion handlers, with no queues or locks of their own. |
| State/event model | @Observable MovieModel held in @State. A weak Objective-C delegate reports progress. MediaExtension protocols use completion-handler callbacks. The decoder posts a readiness notification. |
| Key frameworks/packages | Swift modules SwiftUI, AVKit, VideoToolbox, UniformTypeIdentifiers. System headers <MediaExtension/MediaExtension.h>, <CoreMedia/CoreMedia.h>, <VideoToolbox/VideoToolbox.h>, <CoreVideo/CoreVideo.h>. Quoted local headers "SimpleContainerFormat.h", "RLECodec.h". No Swift packages. |
Project structure
FormatReaderAndDecoder.xcodeproj # 3 targets: app + 2 extensionkit-extension
FormatReaderAndDecoder/ # host app (Swift UI + ObjC exporter)
├── FormatReaderAndDecoderApp.swift
├── ContentView.swift # VideoPlayer, drop target, export UI
├── MovieModel.swift # @Observable owner; registration Task
├── MovieSavePanel.swift # NSSavePanel ViewModifier
├── Info.plist # exports the .simpl UTType
└── RLEMovieExporter/ # ObjC: AVFoundation read -> RLE -> write
├── RLEMovieExporter.{h,m} # pipeline + RLEMovieExporterDelegate
├── AssetVideoReader / AssetAudioReader
├── RLECompressor # 2vuy pixels -> XRLE bytes
├── AssetSampleWriter # QuickTime via AVAssetWriter
└── SimpleContainerWriter # .simpl file writer
SimpleContainerReader/ # format reader extension
├── Info.plist # EXPrincipalClass = ExtensionFormatReaderFactory
├── ExtensionFormatReader.{h,m} # factory + MEFormatReader
├── ExtensionTrackReader.{h,m} # METrackReader (+ video/audio/timecode)
├── ExtensionSampleCursor.{h,m} # MESampleCursor (+ video/audio/timecode)
└── SimpleContainerReader.{h,m} # container parser over MEByteSource
RLEVideoDecoder/ # video decoder extension
├── Info.plist # EXPrincipalClass = ExtensionRLEDecoderFactory
├── ExtensionRLEDecoder.{h,m} # factory + MEVideoDecoder
└── RLEDecompressor.{h,m} # XRLE bytes -> 2vuy pixel buffer
Shared/
├── SimpleContainerFormat.h # on-disk layout (writer <-> reader)
└── RLECodec.h # kRLECodecCode 'XRLE' (exporter <-> decoder)
Structure observations
- Target types: the app is
com.apple.product-type.application(FormatReaderAndDecoder.xcodeproj/project.pbxproj:310). Both extensions arecom.apple.product-type.extensionkit-extension(FormatReaderAndDecoder.xcodeproj/project.pbxproj:327,FormatReaderAndDecoder.xcodeproj/project.pbxproj:344). - Embedding: the app depends on both extension targets (
FormatReaderAndDecoder.xcodeproj/project.pbxproj:304,FormatReaderAndDecoder.xcodeproj/project.pbxproj:305). An “Embed ExtensionKit Extensions” copy phase places both.appexbundles in$(EXTENSIONS_FOLDER_PATH)(FormatReaderAndDecoder.xcodeproj/project.pbxproj:56). - Source membership is strictly per target. The app compiles only the four Swift files and the six
RLEMovieExporter/*.mfiles. The reader compiles only its four.mfiles. The decoder compilesExtensionRLEDecoder.mandRLEDecompressor.m. No implementation file is shared. Shared/contains headers only.SimpleContainerFormat.his imported by the app’s writer (FormatReaderAndDecoder/RLEMovieExporter/SimpleContainerWriter.h:13) and the reader’s parser (SimpleContainerReader/SimpleContainerReader.h:13).RLECodec.his imported by the exporter (FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:9) and the decoder (RLEVideoDecoder/ExtensionRLEDecoder.m:10). These two headers are the only compile-time coupling between targets.- The app target is Swift 5 (
FormatReaderAndDecoder.xcodeproj/project.pbxproj:503), deploys to macOS 15.0 (FormatReaderAndDecoder.xcodeproj/project.pbxproj:494), and bridges Objective-C throughFormatReaderAndDecoder-Bridging-Header.h(FormatReaderAndDecoder.xcodeproj/project.pbxproj:501). That header imports onlyRLEMovieExporter.h(FormatReaderAndDecoder/RLEMovieExporter/FormatReaderAndDecoder-Bridging-Header.h:8).
Overall architecture
flowchart LR
subgraph App["FormatReaderAndDecoder.app"]
UI["ContentView"] --> Model["MovieModel"]
Model --> Exporter["RLEMovieExporter (ObjC)"]
end
subgraph Reader["SimpleContainerReader.appex"]
RF["ExtensionFormatReaderFactory"] --> FR["ExtensionFormatReader"]
end
subgraph Decoder["RLEVideoDecoder.appex"]
DF["ExtensionRLEDecoderFactory"] --> DD["ExtensionRLEDecoder"]
end
Exporter -->|"writes .simpl / XRLE .mov"| Files[("Movie files")]
Model -->|"AVPlayer + MT/VT registration"| Sys["System media stack"]
Sys -->|"EXPrincipalClass, UTType simpl"| RF
Sys -->|"EXPrincipalClass, CodecType XRLE"| DF
Files --> Sys
Reference code
SimpleContainerReader/Info.plist:5 — the reader’s extension-point binding
<key>EXAppExtensionAttributes</key>
<dict>
<key>ClassImplementationID</key>
<string>com.example.apple-samplecode.formatreader.SimpleContainer</string>
<key>EXExtensionPointIdentifier</key>
<string>com.apple.mediaextension.formatreader</string>
<key>EXPrincipalClass</key>
<string>ExtensionFormatReaderFactory</string>
<key>MTFileNameExtensionArray</key>
<array>
<string>simpl</string>
</array>
<key>MTUTTypeArray</key>
<array>
<string>com.example.apple-samplecode.simpleformat</string>
</array>
<!-- ... -->
</dict>
RLEVideoDecoder/Info.plist:9 — the decoder advertises the codec four-character code it handles
<key>CodecInfo</key>
<array>
<dict>
<key>CodecName</key>
<string>MediaExtension RLE Video Codec</string>
<key>CodecType</key>
<string>XRLE</string>
</dict>
</array>
<key>EXExtensionPointIdentifier</key>
<string>com.apple.mediaextension.videodecoder</string>
<key>EXPrincipalClass</key>
<string>ExtensionRLEDecoderFactory</string>
Interpretation
Observed: no target calls into another. The app never imports an extension header. The reader never references the decoder. The decoder knows only the kRLECodecCode constant. Each extension states what it handles declaratively: the reader by UTType and file extension, the decoder by CodecType. Each also names its principal class and carries an entitlement for its extension point (SimpleContainerReader/SimpleContainerReader.entitlements:5, RLEVideoDecoder/RLEVideoDecoder.entitlements:5).
Inference: the system media stack is the only broker between the two extensions. MediaExtension APIs do this routing: they choose the reader from the file’s type and the decoder from the format description’s codec type. Nothing in the sample’s source makes that selection. This is a plug-in architecture with the framework as host. The sample’s own composition is therefore three independently loadable products connected only by shared data contracts (file layout and codec code), not by calls.
Ownership and state
classDiagram
ContentView *-- MovieModel : @State
MovieModel *-- AVPlayer : let player
MovieModel *-- Task : private let registration (detached)
MovieModel *-- RLEMovieExporter : var exporter (per export)
RLEMovieExporter o-- MovieModel : weak delegate
ExtensionFormatReaderFactory ..> ExtensionFormatReader : creates per byte source
ExtensionFormatReader *-- SimpleContainerReader : m_reader
ExtensionFormatReader *-- ExtensionTrackReader : m_tracks
ExtensionTrackReader o-- SimpleContainerReader : shared m_reader
ExtensionSampleCursor o-- SimpleContainerReader : shared m_reader
SimpleContainerReader o-- MEByteSource : m_byteSource
ExtensionRLEDecoderFactory ..> ExtensionRLEDecoder : creates per format
ExtensionRLEDecoder *-- RLEDecompressor : m_decoder
ExtensionRLEDecoder o-- MEVideoDecoderPixelBufferManager : m_pixelBufferManager
Ownership evidence
FormatReaderAndDecoder/MovieModel.swift:13 — app-side state owner
@Observable class MovieModel: NSObject {
// ...
var exporter: RLEMovieExporter?
// ...
var inputURL: URL?
// ...
let player = AVPlayer()
// ...
var progressValue = 0.0
// ...
private let registration = Task.detached {
VTRegisterProfessionalVideoWorkflowVideoDecoders()
MTRegisterProfessionalVideoWorkflowFormatReaders()
}
// ...
}SimpleContainerReader/ExtensionFormatReader.m:35 — one format reader per byte source parses eagerly and shares one container parser with its tracks
- (instancetype)initWithByteSource:(MEByteSource *)byteSource
options:(MEFormatReaderInstantiationOptions *)options
error:(NSError **)error {
// ...
self = [super init];
if (self) {
// Set up the reader.
m_byteSource = byteSource;
m_reader = [[SimpleContainerReader alloc] initWithByteSource:m_byteSource];
// ...
if (err == noErr) {
// ...
[self setupTracks];
[self setupFileInfo];
[self setupMetadata];
}
}
// ...
return self;
}| Owner | Object or state | Relationship | Mutation authority |
|---|---|---|---|
ContentView |
MovieModel, isChoosingExportDestination |
@State private (FormatReaderAndDecoder/ContentView.swift:13, FormatReaderAndDecoder/ContentView.swift:14) |
The view. The save-panel modifier resets the flag through a binding |
MovieModel |
exporter |
Creates a fresh RLEMovieExporter for each export and sets itself as delegate (FormatReaderAndDecoder/MovieModel.swift:46, FormatReaderAndDecoder/MovieModel.swift:47). Releases it when progress reaches 1 (FormatReaderAndDecoder/MovieModel.swift:107) |
MovieModel |
MovieModel |
inputURL, player current item |
Set after a drop, on the main actor (FormatReaderAndDecoder/MovieModel.swift:84, FormatReaderAndDecoder/MovieModel.swift:85) |
MovieModel |
MovieModel |
registration task |
private let. Created at initialization, never cancelled, awaited before each load (FormatReaderAndDecoder/MovieModel.swift:32, FormatReaderAndDecoder/MovieModel.swift:83) |
— |
RLEMovieExporter |
delegate |
weak (FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.h:42), which avoids a retain cycle with MovieModel |
MovieModel |
RLEMovieExporter |
Readers, compressor, writers, vidFmtDesc |
Created in startWriting:, released (and CFReleased) in finishWriting, which dealloc also calls (FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:37, FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:467) |
RLEMovieExporter |
ExtensionFormatReader |
m_reader, m_tracks, m_fileInfo, m_metadata |
Built once in the initializer. The load… methods return them unchanged (SimpleContainerReader/ExtensionFormatReader.m:79) |
Initializer only |
ExtensionTrackReader / ExtensionSampleCursor |
SimpleContainerReader |
A strong reference to the same parser instance (SimpleContainerReader/ExtensionTrackReader.m:26, SimpleContainerReader/ExtensionSampleCursor.m:22). Each cursor owns its m_pts and a retained format description, released in dealloc (SimpleContainerReader/ExtensionSampleCursor.m:30) |
The cursor mutates its own m_pts when it steps (SimpleContainerReader/ExtensionSampleCursor.m:178) |
ExtensionRLEDecoder |
m_decoder, m_pixelBufferManager, m_formatDescription |
Creates RLEDecompressor. Stores the system-supplied pixel-buffer manager. Stores the format-description pointer without CFRetain (RLEVideoDecoder/ExtensionRLEDecoder.m:51, RLEVideoDecoder/ExtensionRLEDecoder.m:52, RLEVideoDecoder/ExtensionRLEDecoder.m:62) |
Initializer only |
Who creates the factories is not visible in the source. The principal classes are named in each Info.plist and have only a plain init (SimpleContainerReader/ExtensionFormatReader.m:17, RLEVideoDecoder/ExtensionRLEDecoder.m:18). Inference: the extension host instantiates them.
Concurrency, scheduling, and thread safety
Evidence limit: actor isolation, async/await, or Task creation does not by itself prove background-thread execution. A Sendable conformance alone does not prove thread-safe mutation.
The app target is Swift 5 (FormatReaderAndDecoder.xcodeproj/project.pbxproj:503) and sets no default actor isolation. MovieModel carries no actor annotation, so its methods are nonisolated. Main-actor placement happens only where the source writes @MainActor.
| Concern | Source mechanism | Verified placement or handoff | Evidence |
|---|---|---|---|
| Blocking setup off the caller | Task.detached stored in private let registration |
Runs the two blocking registration calls. The comment says these cross XPC and run off the main thread. MovieModel retains the task for its lifetime and never cancels it. |
FormatReaderAndDecoder/MovieModel.swift:32 |
| Dependency gate | await self.registration.value |
Each dropped movie waits for registration before loading | FormatReaderAndDecoder/MovieModel.swift:83 |
| Main-actor hop from a framework callback | Task { @MainActor in ... } inside loadFileRepresentation’s completion handler |
Sets inputURL and replaces the player item on the main actor. The queue that runs the completion handler is not shown in the source. |
FormatReaderAndDecoder/MovieModel.swift:78, FormatReaderAndDecoder/MovieModel.swift:80 |
| Main-actor hop from a delegate callback | Task { @MainActor in ... } in progressChanged(_:) |
Writes progressValue and clears exporter on the main actor. The comment says the exporter calls in “from its own background queue”. |
FormatReaderAndDecoder/MovieModel.swift:100 |
| Deferred modal presentation | Task { @MainActor in ... } in onChange |
Defers NSSavePanel.runModal() until after the SwiftUI transaction (stated in the comment), then calls onCompletion |
FormatReaderAndDecoder/MovieSavePanel.swift:30 |
| Export work | Task(priority: .medium) { ... } in nonisolated exportMovie(to:as:) |
The closure has no actor to inherit. The comment states the intent “Do the writing on a background thread”. The synchronous writeRLE… call runs inside this task for the whole export. No handle is kept, so nothing can cancel the task itself. |
FormatReaderAndDecoder/MovieModel.swift:52 |
| Cancellation | C11 atomic_int stopWriting, set by cancelWriting |
Polled by the QuickTime video and audio blocks and by the Simple Container loop | FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.h:39, FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:463, FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:158, FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:344 |
| Pull-based writing (QuickTime path) | requestMediaDataWhenReadyOnQueue: on one serial dispatch_queue_t. A dispatch_group brackets both tracks |
Video and audio blocks run serially on the same queue. The calling thread blocks in dispatch_group_wait until both tracks leave the group. |
FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:87, FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:94, FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:212 |
| Shared mutable block state | __block err, curFrame, vFinished, aFinished, vidPTS, audPTS |
Mutated only inside blocks that the same serial queue runs. The outer function reads err only after dispatch_group_wait. |
FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:49 |
| Reader-side mutual exclusion | dispatch_semaphore_create(1) around copySampleBufferForTime: and dealloc |
Used as a mutex over the asset reader’s buffer state | FormatReaderAndDecoder/RLEMovieExporter/AssetVideoReader.m:59, FormatReaderAndDecoder/RLEMovieExporter/AssetVideoReader.m:218, FormatReaderAndDecoder/RLEMovieExporter/AssetAudioReader.m:244 |
| Async-to-sync adapters | dispatch_semaphore_create(0) |
Waits up to 5 s for loadTracksWithMediaType:completionHandler:. AssetSampleWriter.dealloc waits until finishWritingWithCompletionHandler: signals. |
FormatReaderAndDecoder/RLEMovieExporter/AssetVideoReader.m:46, FormatReaderAndDecoder/RLEMovieExporter/AssetSampleWriter.m:43, FormatReaderAndDecoder/RLEMovieExporter/AssetSampleWriter.m:139 |
| Extension callbacks | MediaExtension completion handlers | The reader’s load…/generate… and the decoder’s decodeFrameFromSampleBuffer: invoke the completion handler synchronously before returning. Neither extension creates a queue, task, or lock. The thread the system uses is not established. |
SimpleContainerReader/ExtensionFormatReader.m:80, SimpleContainerReader/ExtensionTrackReader.m:114, RLEVideoDecoder/ExtensionRLEDecoder.m:108 |
| Decoder back-pressure signal | NSNotificationCenter post of MEVideoDecoderReadyForMoreMediaDataDidChangeNotification |
Posted after every decode. isReadyForMoreMediaData always returns YES. |
RLEVideoDecoder/ExtensionRLEDecoder.m:115, RLEVideoDecoder/ExtensionRLEDecoder.m:236 |
| Semaphore inside a cursor | dispatch_semaphore waiting on stepInPresentationOrderByCount: |
Waits on the cursor’s own completion handler. That handler is already synchronous in this implementation. | SimpleContainerReader/ExtensionSampleCursor.m:104, SimpleContainerReader/ExtensionSampleCursor.m:126 |
Reference code
FormatReaderAndDecoder/MovieModel.swift:97 — the delegate callback arrives off the main actor and hops before touching observable state
func progressChanged(_ progress: Double) {
// The exporter calls this from its own background queue, so update the observable
// properties that the content view reads on the main actor.
Task { @MainActor in
// The content view observes `progressValue`, so the progress appears in the window.
self.progressValue = progress
// Releasing the exporter when the export finishes tells the content view to
// hide the export controls overlay.
if self.progressValue == 1 {
self.exporter = nil
}
}
}FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:86 — serial queue + group for the QuickTime path (abbreviated)
if (err == 0) {
decodingQueue = dispatch_queue_create("com.example.apple-samplecode.FormatReaderAndDecoder.export.decode", DISPATCH_QUEUE_SERIAL);
decodingTrackGroup = dispatch_group_create();
__weak id<RLEMovieExporterDelegate> delegate = _delegate;
// Handle the video track.
dispatch_group_enter(decodingTrackGroup);
[qtWriter videoRequestMediaDataWhenReadyOnQueue:decodingQueue
usingBlock:^{
// ...
}];
}Observations and inferences:
- Observed: the comment at
FormatReaderAndDecoder/MovieModel.swift:98says progress arrives on “its own background queue”. In the QuickTime path, intermediate progress does come from the serialdecodingQueueblock (FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:153). In the Simple Container path, the loop runs on whatever thread runs the Swift export task (FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:341). Both paths send the finalprogressChanged:1.0from that calling thread (FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:228,FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:364). The@MainActorhop makes all of these safe for the UI. - Inference:
MovieModelis not actor-isolated, so the compiler does not enforce main-actor access toexporter. The export task readsexporter(FormatReaderAndDecoder/MovieModel.swift:54) at roughly the same time the main actor assigns it. The design relies on ordering, not on isolation. - Inference: the export task blocks a Swift concurrency thread for the whole export, because
writeRLE…is synchronous and itself waits on a dispatch group. The comment calls this “a background thread”. A dedicated queue would avoid tying up a cooperative-pool thread.
State propagation, frameworks, and dependencies
Evidence limit: an import proves a source-level compilation dependency at the cited line. It does not prove runtime use, architectural adoption, or whether a Swift package is a direct application dependency.
| Category | Mechanism or module | Verified role | Evidence |
|---|---|---|---|
| State propagation | @Observable + @State |
ContentView reads inputURL, exporter, progressValue, and suggestedExportName to drive opacity, overlays, the title, and the disabled state |
FormatReaderAndDecoder/MovieModel.swift:13, FormatReaderAndDecoder/ContentView.swift:26, FormatReaderAndDecoder/ContentView.swift:70 |
| State propagation | @Binding |
MovieSavePanelModifier observes isPresented and resets it to false |
FormatReaderAndDecoder/MovieSavePanel.swift:16, FormatReaderAndDecoder/MovieSavePanel.swift:26 |
| Callback | DropDelegate |
MovieModel.performDrop(info:) is the drop target handed to onDrop(of:delegate:) |
FormatReaderAndDecoder/ContentView.swift:25, FormatReaderAndDecoder/MovieModel.swift:67 |
| Callback | RLEMovieExporterDelegate (local Objective-C protocol) |
Progress from exporter to MovieModel |
FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.h:22 |
| Swift module import | SwiftUI, AVKit |
App UI and VideoPlayer |
FormatReaderAndDecoder/ContentView.swift:9, FormatReaderAndDecoder/ContentView.swift:10 |
| Swift module import | VideoToolbox, UniformTypeIdentifiers |
Registration calls and UTType |
FormatReaderAndDecoder/MovieModel.swift:9, FormatReaderAndDecoder/MovieModel.swift:10 |
| Local header (bridged) | "RLEMovieExporter.h" |
The only Objective-C surface exposed to Swift | FormatReaderAndDecoder/RLEMovieExporter/FormatReaderAndDecoder-Bridging-Header.h:8 |
| System header | <MediaExtension/MediaExtension.h> |
Reader and decoder protocol declarations | SimpleContainerReader/ExtensionFormatReader.h:11, RLEVideoDecoder/ExtensionRLEDecoder.h:13 |
| System header | <CoreMedia/CoreMedia.h>, <VideoToolbox/VideoToolbox.h>, <CoreVideo/CoreVideo.h> |
Time, format descriptions, sample and pixel buffers | RLEVideoDecoder/ExtensionRLEDecoder.h:11, RLEVideoDecoder/ExtensionRLEDecoder.h:12, RLEVideoDecoder/RLEDecompressor.h:12 |
| Local shared header | "SimpleContainerFormat.h", "RLECodec.h" |
Cross-target data contracts (see Structure observations) | Shared/SimpleContainerFormat.h:91, Shared/RLECodec.h:13 |
| Linked frameworks | MediaExtension.framework (both extensions), VideoToolbox.framework (decoder) |
Link-phase entries | FormatReaderAndDecoder.xcodeproj/project.pbxproj:15, FormatReaderAndDecoder.xcodeproj/project.pbxproj:14, FormatReaderAndDecoder.xcodeproj/project.pbxproj:19 |
| Type declaration | UTExportedTypeDeclarations conforming to com.apple.mediaextension-content |
Declared in both the app and the reader Info.plist |
FormatReaderAndDecoder/Info.plist:10, SimpleContainerReader/Info.plist:29 |
The project declares no Swift package. The app target’s link phase lists no framework. A MediaToolbox.framework file reference exists in the project but is not in any link phase.
Class and protocol design
classDiagram
class MEFormatReaderExtension {
<<protocol>>
}
class MEFormatReader {
<<protocol>>
}
class METrackReader {
<<protocol>>
}
class MESampleCursor {
<<protocol>>
}
class MEVideoDecoderExtension {
<<protocol>>
}
class MEVideoDecoder {
<<protocol>>
}
ExtensionFormatReaderFactory ..|> MEFormatReaderExtension
ExtensionFormatReader ..|> MEFormatReader
ExtensionTrackReader ..|> METrackReader
ExtensionVideoTrackReader --|> ExtensionTrackReader
ExtensionAudioTrackReader --|> ExtensionTrackReader
ExtensionTimecodeTrackReader --|> ExtensionTrackReader
ExtensionSampleCursor ..|> MESampleCursor
ExtensionVideoSampleCursor --|> ExtensionSampleCursor
ExtensionAudioSampleCursor --|> ExtensionSampleCursor
ExtensionTimecodeSampleCursor --|> ExtensionSampleCursor
ExtensionRLEDecoderFactory ..|> MEVideoDecoderExtension
ExtensionRLEDecoder ..|> MEVideoDecoder
Reference code
SimpleContainerReader/ExtensionFormatReader.h:13 — principal class and per-file reader conformances
@interface ExtensionFormatReaderFactory : NSObject <MEFormatReaderExtension>
- (instancetype)init;
- (id<MEFormatReader>)formatReaderWithByteSource:(MEByteSource *)primaryByteSource
options:(MEFormatReaderInstantiationOptions *)options
error:(NSError **)error;
@endRLEVideoDecoder/ExtensionRLEDecoder.h:15 — decoder principal class
@interface ExtensionRLEDecoderFactory : NSObject <MEVideoDecoderExtension>
- (instancetype)init;
- (id<MEVideoDecoder>)videoDecoderWithCodecType:(CMVideoCodecType)codecType
videoFormatDescription:(CMVideoFormatDescriptionRef)videoFormatDescription
videoDecoderSpecifications:(NSDictionary<NSString *, id> *)videoDecoderSpecifications
extensionDecoderPixelBufferManager:(MEVideoDecoderPixelBufferManager *)extensionDecoderPixelBufferManager
error:(NSError **)error;
@end| Type | Responsibility | Depends on or conforms to |
|---|---|---|
ExtensionFormatReaderFactory |
EXPrincipalClass. Returns an id<MEFormatReader> for each byte source |
MEFormatReaderExtension |
ExtensionFormatReader |
Parses once, then returns file info, track readers, and metadata | MEFormatReader, SimpleContainerReader |
ExtensionTrackReader (+ video/audio/timecode subclasses) |
Track info, duration, data rate. Creates sample cursors | METrackReader |
ExtensionSampleCursor (+ video/audio/timecode subclasses) |
Position in a track. Steps and reports sync/dependency info and byte locations | MESampleCursor, NSCopying via copyWithZone: |
SimpleContainerReader |
Container parsing and format-description creation over an MEByteSource |
SimpleContainerFormat.h |
ExtensionRLEDecoderFactory |
EXPrincipalClass. Returns an id<MEVideoDecoder> for each format description |
MEVideoDecoderExtension |
ExtensionRLEDecoder |
Pixel-buffer attributes, per-frame decode, attachment propagation | MEVideoDecoder, RLEDecompressor, MEVideoDecoderPixelBufferManager |
RLEDecompressor |
Pure decode of one XRLE sample into a locked 2vuy pixel buffer | CoreMedia, CoreVideo |
RLEMovieExporter |
Orchestrates read → compress → write for both output formats | AssetVideoReader, AssetAudioReader, RLECompressor, AssetSampleWriter, SimpleContainerWriter |
MovieModel |
App state, drop handling, export start/cancel, progress hop | NSObject, DropDelegate, RLEMovieExporterDelegate |
Observed: MovieModel subclasses NSObject only to satisfy RLEMovieExporterDelegate <NSObject>, as its own comment says (FormatReaderAndDecoder/MovieModel.swift:12). The track readers and cursors use Objective-C subclassing for media-type differences. ExtensionFormatReader.setupFileInfo also tests exact classes with [meTrack class] == [ExtensionVideoTrackReader class] (SimpleContainerReader/ExtensionFormatReader.m:134).
Access control
| Symbol | Access | Verified effect | Likely rationale |
|---|---|---|---|
MovieModel.registration |
Swift private let |
Only MovieModel can await or replace the registration task (FormatReaderAndDecoder/MovieModel.swift:32) |
Inference: other code must go through the drop path, which awaits it. |
ContentView.movieModel, isChoosingExportDestination |
Swift private (@State) |
Only ContentView reaches the storage (FormatReaderAndDecoder/ContentView.swift:13) |
Inference: the view owns the model’s lifetime. |
MovieSavePanelModifier, destinationFromSavePanel() |
Swift private |
The file exposes only the View.movieSavePanel(...) extension method (FormatReaderAndDecoder/MovieSavePanel.swift:15, FormatReaderAndDecoder/MovieSavePanel.swift:38, FormatReaderAndDecoder/MovieSavePanel.swift:62) |
Inference: keep the AppKit modal detail behind one SwiftUI-style API. |
exporter, inputURL, progressValue |
Swift implicit internal (no private(set)) |
Any app-module code may write them | Inference: simplicity. Mutation discipline is by convention. |
RLEMovieExporter ivars |
Objective-C @interface { ... } ivars with default @protected visibility |
Not exposed as properties. Swift sees only the declared methods and the delegate property (FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.h:28) |
Inference: keep pipeline state inside the Objective-C object. |
RLEDecompressRow, RLEDecompressAllRows |
C static |
Internal linkage. Visible only inside RLEDecompressor.m (RLEVideoDecoder/RLEDecompressor.m:15, RLEVideoDecoder/RLEDecompressor.m:16) |
Inference: hide codec internals behind the single decodeFrame:toBuffer: method. |
| Extension headers | Objective-C header boundary | Extension headers are not in the app’s bridging header, and no target imports another target’s headers | Inference: each .appex is a separate binary. The header boundary matches the process boundary. |
Objective-C headers are not equivalent to Swift privacy. Every method declared in an @interface can be messaged by any code that imports the header. In this project, cross-target isolation comes from build membership, not from access modifiers.
Reference code
FormatReaderAndDecoder/MovieSavePanel.swift:15 — private modifier type behind an internal View extension
private struct MovieSavePanelModifier: ViewModifier {
@Binding var isPresented: Bool
let defaultFilename: String
let contentTypes: [UTType]
let onCompletion: (URL, UTType) -> Void
// ...
}Logic ownership and placement
| Logic | Owning type or file | Placement rationale |
|---|---|---|
| Opting the app into installed media extensions | MovieModel.registration |
Observed: VTRegisterProfessionalVideoWorkflowVideoDecoders() and MTRegisterProfessionalVideoWorkflowFormatReaders() run once, detached. The comment says the app opts into every installed extension, not only its own (FormatReaderAndDecoder/MovieModel.swift:27). |
| Container parsing and byte access | SimpleContainerReader |
Observed: reads through MEByteSource readDataOfLength:fromOffset:toDestination:bytesRead:error: (SimpleContainerReader/SimpleContainerReader.m:802), not through file paths. The URL/NSFileHandle path exists but no source calls initWithFileURL:. |
| Codec identity on read | SimpleContainerReader.parseVideoTrackHeader: |
Observed: creates the video CMVideoFormatDescription from the file’s stored formatID (SimpleContainerReader/SimpleContainerReader.m:617). |
| Sample byte location (not bytes) | ExtensionVideoSampleCursor.sampleLocationReturningError: |
Observed: returns MESampleLocation (byte source + offset/length) (SimpleContainerReader/ExtensionSampleCursor.m:274). The extension’s own block-buffer read method has no caller. |
| Synthesized timecode | ExtensionTimecodeSampleCursor |
Observed: builds a one-sample CMSampleBuffer itself (SimpleContainerReader/ExtensionSampleCursor.m:416), because the container stores no timecode. |
| Codec acceptance | ExtensionRLEDecoder init / canAcceptFormatDescription: |
Observed: rejects any codec other than kRLECodecCode. Accepts a new format description only when the codec and dimensions match (RLEVideoDecoder/ExtensionRLEDecoder.m:57, RLEVideoDecoder/ExtensionRLEDecoder.m:128). |
| Pixel decode | RLEDecompressor.decodeFrame:toBuffer: |
Observed: makes the data contiguous, checks the 4-byte size header, and decodes into a locked 2vuy buffer (RLEVideoDecoder/RLEDecompressor.m:48, RLEVideoDecoder/RLEDecompressor.m:73). |
| Color attachments | ExtensionRLEDecoder.addAttachmentsFromFormatDescription:toPixelBuffer: |
Observed: copies format extensions to the pixel buffer and falls back to Rec. 709. At RLEVideoDecoder/ExtensionRLEDecoder.m:206 the fallback passes the key constant kCMFormatDescriptionExtension_YCbCrMatrix as the matrix value. |
| Codec identity on write | RLEMovieExporter.newVideoFormatDescriptionFromSource: |
Observed: stamps kRLECodecCode on the output format description (FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m:527). The Simple Container writer stores that code as the track’s formatID (FormatReaderAndDecoder/RLEMovieExporter/SimpleContainerWriter.m:419). |
Design patterns
| Pattern | Source evidence | Purpose or tradeoff |
|---|---|---|
| Factory | SimpleContainerReader/ExtensionFormatReader.m:22 (formatReaderWithByteSource:options:error:), RLEVideoDecoder/ExtensionRLEDecoder.m:23 (videoDecoderWithCodecType:...). Media-type subclass selection in SimpleContainerReader/ExtensionTrackReader.m:106 and SimpleContainerReader/ExtensionFormatReader.m:102 |
The framework depends only on the MEFormatReaderExtension/MEVideoDecoderExtension protocols. The extension supplies the concrete products. Per-file and per-stream instances keep state out of the long-lived principal class. |
| Delegate or data-source callbacks | FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.h:22 (RLEMovieExporterDelegate, weak at FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.h:42). FormatReaderAndDecoder/MovieModel.swift:66 (DropDelegate) |
The Objective-C pipeline reports progress without knowing about SwiftUI. The weak reference prevents an owner ↔︎ exporter cycle. |
| Binding-based state propagation | FormatReaderAndDecoder/MovieSavePanel.swift:16 (@Binding var isPresented), FormatReaderAndDecoder/ContentView.swift:53 |
The modifier consumes a one-shot trigger and resets it, while ContentView stays the owner. |
Removed from the draft: “Protocol-oriented abstraction”. The protocols that shape the design (MEFormatReader, METrackReader, MESampleCursor, MEVideoDecoder, and their extension protocols) are framework contracts that the sample conforms to. They are not abstractions the sample designs or substitutes across. Its only local protocol is the single-method exporter delegate, and its variation uses Objective-C subclassing.
Main application flow
Playing a dropped .simpl movie: the scope is the source-visible handoffs. The system’s internal routing is shown as one participant.
sequenceDiagram
actor User
participant Model as MovieModel
participant Sys as System media stack
participant FR as ExtensionFormatReader
participant Cur as ExtensionVideoSampleCursor
participant Dec as ExtensionRLEDecoder
User->>Model: performDrop(info:)
Model->>Model: await registration.value (MainActor task)
Model->>Sys: player.replaceCurrentItem(AVPlayerItem(url:))
Sys->>FR: factory formatReaderWithByteSource: (UTType simpl)
FR-->>Sys: track readers (format desc codec = file formatID)
Sys->>Cur: generateSampleCursor... / sampleLocationReturningError:
Cur-->>Sys: MESampleLocation (byte source, offset, length)
Sys->>Dec: factory videoDecoderWithCodecType: XRLE
Sys->>Dec: decodeFrameFromSampleBuffer:
Dec-->>Sys: CVImageBuffer via completion handler
Reference code
SimpleContainerReader/ExtensionSampleCursor.m:264 — the reader hands back where the bytes are, not the decoded or even the compressed frame
- (MESampleLocation *)sampleLocationReturningError:(NSError **)error {
int err;
uint64_t offset, size;
AVSampleCursorStorageRange storageRange;
MESampleLocation *sampleLocation = nil;
err = [m_reader getVideoSampleInfoAtTime:m_pts sampleOffset:&offset sampleSize:&size];
if (err == noErr) {
storageRange.offset = offset;
storageRange.length = size;
sampleLocation = [[MESampleLocation alloc] initWithByteSource:[m_reader byteSource] sampleLocation:storageRange];
} else if (error != nil) {
*error = [NSError errorWithDomain:MediaExtensionErrorDomain code:MEErrorLocationNotAvailable userInfo:nil];
}
return sampleLocation;
}RLEVideoDecoder/ExtensionRLEDecoder.m:84 — the decoder receives a CMSampleBuffer and fills a system-managed pixel buffer
if (sampleBuffer != NULL && m_decoder != nil && ![options doNotOutputFrame]) {
// Create the output pixel buffer.
outputPixelBuffer = [m_pixelBufferManager createPixelBufferAndReturnError:&error];
// Decode the frame.
err = [m_decoder decodeFrame:sampleBuffer toBuffer:outputPixelBuffer];
if (err == noErr) {
// ...
} else {
// ...
}
} else {
// ...
}Reader → decoder data flow, as source-visible facts. (1) The exporter writes kRLECodecCode as the video track formatID. (2) The reader turns formatID into the track’s CMVideoFormatDescription codec type. (3) The reader returns sample byte ranges in an MESampleLocation. (4) The decoder’s Info.plist claims CodecType XRLE, and its factory re-checks kRLECodecCode. (5) The decoder consumes a CMSampleBuffer whose data begins with a 4-byte big-endian size (read in RLEDecompressor’s decodeFrame:toBuffer:). Inference: the system reads the located bytes, wraps them in a sample buffer with the reader’s format description, and selects the decoder by codec type. No sample code performs those steps. The same inference explains why an exported XRLE QuickTime movie needs only the decoder extension.
Naming conventions
- Types: the
Extension…prefix marks classes that conform to MediaExtension protocols (ExtensionFormatReader,ExtensionTrackReader,ExtensionSampleCursor,ExtensionRLEDecoder). The…Factorysuffix marks theEXPrincipalClasstypes. Media-type subclasses insert the medium (ExtensionVideoTrackReader,ExtensionTimecodeSampleCursor). Helpers without the prefix are format or codec logic with no framework role (SimpleContainerReader,RLEDecompressor,RLECompressor,SimpleContainerWriter). App-side Swift uses…Model,…View, and…Modifier. - Protocols: the one local protocol uses the Cocoa
…Delegatesuffix (RLEMovieExporterDelegate). - Methods: Objective-C methods follow Cocoa conventions:
copy…/new…return retained Core Foundation objects (copyFormatDescriptionForMediaType:,newVideoFormatDescriptionFromSource:),…ReturningError:uses an error out-parameter,load…WithCompletionHandler:delivers asynchronously, andinit…initializes. Container-parser methods return Cintstatus codes. Swift methods are commands (exportMovie(to:as:),cancelMovieWriting()). - Instance variables: an
m_prefix in Objective-C (m_reader,m_pixelBufferManager). The exporter’s ivars have no prefix. - Identifiers and files: one
{h,m}pair per primary class, with media-type subclasses in the same pair. Constants usekplus four-character codes (kRLECodecCode 'XRLE',kSimpleContainerMovieID 'smpl'). Bundle and type identifiers share thecom.example.apple-samplecode.prefix.
Architecture takeaways
- A MediaExtension feature is a set of independent binaries joined by declarative metadata (
EXPrincipalClass, UTTypes,CodecType, entitlements) and shared data contracts. Keep cross-target coupling to headers that define formats, asShared/does. - Make the principal class a thin factory and put state in per-file or per-stream objects. In this sample the reader object owns the parser, and every track and cursor shares that parser.
- Readers describe and locate. Decoders transform. The reader returns
MESampleLocationbyte ranges and a format description whose codec type selects the decoder. It never decodes and never calls the decoder. - On the host side, keep blocking framework calls (
…Register…, synchronous export) off the main actor, and hop back withTask { @MainActor in }before mutating observable state. Without actor annotations on the model, that discipline is by convention, not checked by the compiler. - For cancellation across a Swift/Objective-C boundary, a C11 atomic flag polled by the work loop is a minimal, explicit mechanism. Note that the sample’s Swift task handle is not retained, so the task itself is never cancelled.
Source map
| Source file | Relevant symbols |
|---|---|
FormatReaderAndDecoder/ContentView.swift |
ContentView, @State movieModel, onDrop, movieSavePanel |
FormatReaderAndDecoder/MovieModel.swift |
MovieModel, registration, exportMovie(to:as:), performDrop(info:), progressChanged(_:), UTType.simpleContainerFormat |
FormatReaderAndDecoder/MovieSavePanel.swift |
MovieSavePanelModifier, destinationFromSavePanel(), movieSavePanel(...) |
FormatReaderAndDecoder/Info.plist |
UTExportedTypeDeclarations (com.example.apple-samplecode.simpleformat) |
FormatReaderAndDecoder/RLEMovieExporter/FormatReaderAndDecoder-Bridging-Header.h |
Bridging import |
FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.h |
RLEMovieExporterDelegate, RLEMovieExporter, stopWriting |
FormatReaderAndDecoder/RLEMovieExporter/RLEMovieExporter.m |
writeRLEQuickTimeMovie:, writeRLESimpleFormatMovie:, cancelWriting, finishWriting, newVideoFormatDescriptionFromSource: |
FormatReaderAndDecoder/RLEMovieExporter/AssetVideoReader.m |
m_getBufferSync, copySampleBufferForTime: |
FormatReaderAndDecoder/RLEMovieExporter/AssetAudioReader.m |
copySampleBufferForTime:sampleCount: |
FormatReaderAndDecoder/RLEMovieExporter/AssetSampleWriter.m |
m_semaphore, finishWriting |
FormatReaderAndDecoder/RLEMovieExporter/SimpleContainerWriter.h |
SimpleContainerWriter |
FormatReaderAndDecoder/RLEMovieExporter/SimpleContainerWriter.m |
Video track formatID |
SimpleContainerReader/Info.plist |
EXPrincipalClass, MTUTTypeArray, MTFileNameExtensionArray |
SimpleContainerReader/SimpleContainerReader.entitlements |
com.apple.developer.mediaextension.formatreader |
SimpleContainerReader/ExtensionFormatReader.h |
ExtensionFormatReaderFactory, ExtensionFormatReader |
SimpleContainerReader/ExtensionFormatReader.m |
Factory method, eager setup, load… methods |
SimpleContainerReader/ExtensionTrackReader.m |
ExtensionTrackReader and subclasses, cursor creation |
SimpleContainerReader/ExtensionSampleCursor.m |
ExtensionSampleCursor and subclasses, sampleLocationReturningError: |
SimpleContainerReader/SimpleContainerReader.h |
SimpleContainerReader |
SimpleContainerReader/SimpleContainerReader.m |
readDataOfLength:fromOffset:, parseVideoTrackHeader: |
RLEVideoDecoder/Info.plist |
EXPrincipalClass, CodecInfo / CodecType |
RLEVideoDecoder/RLEVideoDecoder.entitlements |
com.apple.developer.mediaextension.videodecoder |
RLEVideoDecoder/ExtensionRLEDecoder.h |
ExtensionRLEDecoderFactory, ExtensionRLEDecoder |
RLEVideoDecoder/ExtensionRLEDecoder.m |
Factory method, decodeFrameFromSampleBuffer:, canAcceptFormatDescription:, attachments |
RLEVideoDecoder/RLEDecompressor.h |
RLEDecompressor |
RLEVideoDecoder/RLEDecompressor.m |
decodeFrame:toBuffer:, RLEDecompressRow, RLEDecompressAllRows |
Shared/SimpleContainerFormat.h |
SimpleMovieHeader, SimpleVideoTrackInfo |
Shared/RLECodec.h |
kRLECodecCode |
FormatReaderAndDecoder.xcodeproj/project.pbxproj |
Targets, embed phase, dependencies, bridging header, Swift version |