Filmtone / iOS Bridge
Reaching iOS PhotoLibrary, Core Image, and ActivityKit through a Capacitor native plugin — implementing FilmtoneMediaPlugin's nine methods
Filmtone iOS is a Capacitor 7.4 hybrid app, but `FilmtoneMediaPlugin` exposes nine native methods to the TypeScript side: `pickSource` (PhotoLibrary), `pickLutFile` (DocumentPicker), `probeSource` (AVAsset metadata), `renderPreviewFrame` (Core Image), `runExport` (Core Video write), `saveToPhotos` (PHPhotoLibrary), `shareOutput` (UIActivityViewController), `cancelExport`, `handleMemoryWarning`. Each is published via Swift `@objc`, with a TypeScript proxy in `src/native/filmtoneMedia.ts`. Bridge changes ship in dual-side PRs (single-side updates are the most common runtime breakage).
- Published
- April 29, 2026
- Topics
- Filmtone · Capacitor · iOS · Native Plugin
Capacitor 7.4 hybrid layout
Filmtone iOS is a hybrid app on top of Capacitor 7.4.3. It hosts the web shell (packages/film-lab-ui + React 19.2) inside a WKWebView, and exposes iOS-only APIs (PhotoLibrary, Core Image, AVFoundation, ActivityKit) to TypeScript through the FilmtoneMediaPlugin bridge. Only the things the web layer can't reach become native; UI and editor logic stay in the web side.
Eight bridge methods
// apps/capacitor-film-lab-ios/ios/App/App/FilmtoneMediaPlugin.swift
@objc(FilmtoneMediaPlugin)
final class FilmtoneMediaPlugin: CAPPlugin, CAPBridgedPlugin {
let identifier = "FilmtoneMediaPlugin"
let jsName = "FilmtoneMedia"
let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "pickSource", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "pickLutFile", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "probeSource", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "renderPreviewFrame", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "runExport", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "saveToPhotos", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "shareOutput", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "cancelExport", returnType: CAPPluginReturnPromise),
]
// ...
}What each method owns
pickSource— picks a photo or video viaPHPickerViewControllerpickLutFile— picks a.cubefile viaUIDocumentPickerViewController(accepts{ slot?: "inputLut" | "creativeLut" })probeSource— extracts codec, color, resolution, duration, and input-transform policy fromAVAssetrenderPreviewFrame— renders one Core Image preview frame for the current project staterunExport— full export (mezzanine, depth, sidecar). Streams progress to TS via theexportProgressnotify eventsaveToPhotos— writes the output to PhotoLibrary viaPHPhotoLibraryshareOutput— presentsUIActivityViewControllercancelExport— stops an in-flight export throughExportCancelController
TypeScript proxy
The TS side calls registerPlugin<FilmtoneMediaPlugin>("FilmtoneMedia", { web: ... }) and pins the contract in the interface type. The web fallback (filmtoneMedia.web.ts) only runs in dev when the web shell is exercised standalone. Export progress is consumed via addListener("exportProgress", ...).
// apps/capacitor-film-lab-ios/src/native/filmtoneMedia.ts
export interface FilmtoneMediaPlugin {
pickSource(): Promise<SourceInfo | null>;
pickLutFile(options?: { slot?: "inputLut" | "creativeLut" }): Promise<PickedLutFile | null>;
probeSource(options: { uri: string }): Promise<SourceProbe>;
renderPreviewFrame(request: Phase0ExportRequest): Promise<Phase0PreviewRenderResult>;
runExport(request: Phase0ExportRequest): Promise<Phase0ExportResult>;
saveToPhotos(options: { uri: string }): Promise<void>;
shareOutput(options: { uri: string }): Promise<void>;
cancelExport(): Promise<void>;
addListener(
eventName: "exportProgress",
listenerFunc: (progress: Phase0ExportProgress) => void,
): Promise<PluginListenerHandle>;
}
export const filmtoneMedia = registerPlugin<FilmtoneMediaPlugin>(
"FilmtoneMedia",
{ web: () => import("./filmtoneMedia.web").then((mod) => new mod.FilmtoneMediaWeb()) },
);runExport — detached Task plus a progress stream
runExport decodes a Phase0ExportRequestDTO, builds a session via FilmtoneMediaRuntime.makeExportSession, then launches runtime.runExport inside a Task.detached(priority: .userInitiated). Progress comes back through a closure and goes to TS as notifyListeners("exportProgress", ...). Concurrent exports are rejected with FilmtoneMediaError.exportBusy if currentExportTask is non-nil — there's no silent fallback.
@objc func runExport(_ call: CAPPluginCall) {
guard currentExportTask == nil else {
reject(call, with: FilmtoneMediaError.exportBusy)
return
}
// ...
currentExportTask = Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
do {
let exportResult = try await runtime.runExport(
request: request,
sourceURL: sourceURL,
session: exportSession,
collector: benchmarkCollector
) { progress in
let payload: [String: Any] = [
"stage": progress.stage.rawValue,
"progress": progress.progress,
"currentFrame": progress.currentFrame as Any,
"totalFrames": progress.totalFrames as Any,
]
DispatchQueue.main.async {
self.notifyListeners("exportProgress", data: payload)
}
}
// resolve on main actor...
} catch { /* reject + benchmark */ }
}
}cancelExport and Live Activity share one stop point
cancelExport calls ExportCancelController.shared.cancel(reason: .userViaUI) and idempotently nudges currentExportSession?.cancel() and task?.cancel(). The Live Activity side (Lock Screen and Dynamic Island) routes through CancelExportIntent, which calls the same controller. UI, Lock Screen, and Dynamic Island all funnel into one cancel point with a unified reason vocabulary that ends up in the benchmark record.
Memory warning observer (not exposed to TS)
The plugin subscribes to UIApplication.didReceiveMemoryWarningNotification in load() and increments a counter from @objc private func handleMemoryWarning(). It's deliberately not registered as a pluginMethod (TS shouldn't call it). The current value lands in Phase0ExportBenchmarkRecordDTO.memoryWarningCount for post-export analysis.
override func load() {
super.load()
// ... cache / mezzanine / runtime init ...
NotificationCenter.default.addObserver(
self,
selector: #selector(handleMemoryWarning),
name: UIApplication.didReceiveMemoryWarningNotification,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc private func handleMemoryWarning() {
memoryWarningCount += 1
}Double-save guard for saveToPhotos
saveToPhotos keeps inFlightPhotoSaveURI and lastSavedPhotoURI behind an NSLock, rejecting concurrent saves of the same URI and any second save with FilmtoneMediaError.saveFailed. The app's invariant — never write the same export to Photos twice — is enforced physically at the plugin entry.