Filmtone / iOS Bridge
Capacitor native plugin で iOS PhotoLibrary / Core Image / ActivityKit に届く — `FilmtoneMediaPlugin` 9 メソッドの実装パターン
Filmtone iOS は Capacitor 7.4 のハイブリッドアプリだが、`FilmtoneMediaPlugin` で 9 つの native メソッドを TypeScript 側に露出する: `pickSource` (PhotoLibrary)、`pickLutFile` (DocumentPicker)、`probeSource` (AVAsset metadata)、`renderPreviewFrame` (Core Image)、`runExport` (Core Video write)、`saveToPhotos` (PHPhotoLibrary)、`shareOutput` (UIActivityViewController)、`cancelExport`、`handleMemoryWarning`。各メソッドは Swift `@objc` で公開され、TypeScript 側 `src/native/filmtoneMedia.ts` がプロキシを定義する。bridge 変更は両側を同時 PR で出す (片肺更新が runtime 事故の最頻原因)。
- Published
- 2026年4月29日
- Topics
- Filmtone · Capacitor · iOS · Native Plugin
Capacitor 7.4 のハイブリッド構成
Filmtone iOS は Capacitor 7.4.3 の上に乗ったハイブリッドアプリ。Web (`packages/film-lab-ui` + React 19.2) を `WKWebView` でホストしつつ、PhotoLibrary・Core Image・AVFoundation・ActivityKit のような iOS native API は `FilmtoneMediaPlugin` 経由で TypeScript に露出する。Web 単体では届かない場所だけを native に出し、UI と editor logic は Web 側に閉じる構造。
8 メソッドの bridge surface
// 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),
]
// ...
}各メソッドの責務
- `pickSource` — `PHPickerViewController` でフォトライブラリから写真 / 動画を選択
- `pickLutFile` — `UIDocumentPickerViewController` で `.cube` ファイル選択 (`{ slot?: "inputLut" | "creativeLut" }` を受ける)
- `probeSource` — `AVAsset` から codec / 色域 / 解像度 / 尺 / 入力 transform policy を抽出
- `renderPreviewFrame` — 現在の project state で 1 frame の Core Image プレビューを生成
- `runExport` — 本書き出し (mezzanine / depth / sidecar 含む)。進捗を `exportProgress` notify で TS に流す
- `saveToPhotos` — `PHPhotoLibrary` 経由で出力をフォトライブラリに書き戻す
- `shareOutput` — `UIActivityViewController` 共有シートを提示
- `cancelExport` — 進行中 export を停止 (`ExportCancelController` 経由)
TypeScript proxy
TS 側は `registerPlugin<FilmtoneMediaPlugin>("FilmtoneMedia", { web: ... })` で plugin を登録し、interface を型で固定する。Web fallback (`filmtoneMedia.web.ts`) は dev で web shell を直接動かす時だけ動く。`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 と progress stream
`runExport` は `Phase0ExportRequestDTO` を decode し、`FilmtoneMediaRuntime.makeExportSession` で session を立てたあと、`Task.detached(priority: .userInitiated)` の中で `runtime.runExport` を回す。進捗は closure 引数で受けて `notifyListeners("exportProgress", ...)` で TS に流す。同時 export は `currentExportTask != nil` の時点で `FilmtoneMediaError.exportBusy` で弾く (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 と Live Activity の単一停止口
`cancelExport` は `ExportCancelController.shared.cancel(reason: .userViaUI)` を呼びつつ、`currentExportSession?.cancel()` と `task?.cancel()` を idempotent に踏む。Live Activity 側 (Lock Screen / Dynamic Island) からは `CancelExportIntent` が同じ controller を呼ぶので、UI / Lock Screen / Dynamic Island の 3 経路から 1 つの停止口に流れる。停止 reason を統一語彙にしてあるので、benchmark record にもそのまま乗る。
memory warning observer (TS には露出しない)
Plugin は `load()` で `UIApplication.didReceiveMemoryWarningNotification` を購読し、`@objc private func handleMemoryWarning()` で `memoryWarningCount` を回す。これは `pluginMethods` には載せない (TS から呼び出す対象ではないため)。export 中の memory pressure は `Phase0ExportBenchmarkRecordDTO.memoryWarningCount` に乗って書き出し後の解析に使える。
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
}saveToPhotos の二重保存ガード
`saveToPhotos` は `NSLock` で `inFlightPhotoSaveURI` と `lastSavedPhotoURI` を持ち、同じ URI への同時保存と二度目の保存を `FilmtoneMediaError.saveFailed` で弾く。Photos へ二重に書かない、というアプリ側の不変条件をプラグイン入口で物理的に守る。