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 via `PHPickerViewController`
- `pickLutFile` — picks a `.cube` file via `UIDocumentPickerViewController` (accepts `{ slot?: "inputLut" | "creativeLut" }`)
- `probeSource` — extracts codec, color, resolution, duration, and input-transform policy from `AVAsset`
- `renderPreviewFrame` — renders one Core Image preview frame for the current project state
- `runExport` — full export (mezzanine, depth, sidecar). Streams progress to TS via the `exportProgress` notify event
- `saveToPhotos` — writes the output to PhotoLibrary via `PHPhotoLibrary`
- `shareOutput` — presents `UIActivityViewController`
- `cancelExport` — stops an in-flight export through `ExportCancelController`
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.