From cb118d7d078dd25fbbad3e67180eb5b6639f6044 Mon Sep 17 00:00:00 2001 From: shaosibo Date: Mon, 12 Oct 2026 00:46:34 +0800 Subject: [PATCH] feat(video): expose native rendering mode transitions on Darwin --- media_kit_video/README.md | 93 ++++++ .../Classes/plugin/MediaKitVideoPlugin.swift | 36 ++- .../darwin/Classes/plugin/TextureSW.swift | 19 +- .../darwin/Classes/plugin/VideoOutput.swift | 117 ++++++-- .../Classes/plugin/VideoOutputManager.swift | 245 ++++++++++++++-- .../media_kit_video/plugin/TextureHW.swift | 21 +- .../plugin/gles/OpenGLESHelpers.swift | 20 +- .../media_kit_video/plugin/TextureHW.swift | 13 +- .../tool/rendering_mode_test/main.swift | 266 ++++++++++++++++++ .../tool/rendering_mode_test/output.swift | 149 ++++++++++ .../tool/rendering_mode_test/run.sh | 18 ++ 11 files changed, 912 insertions(+), 85 deletions(-) create mode 100644 media_kit_video/tool/rendering_mode_test/main.swift create mode 100644 media_kit_video/tool/rendering_mode_test/output.swift create mode 100755 media_kit_video/tool/rendering_mode_test/run.sh diff --git a/media_kit_video/README.md b/media_kit_video/README.md index 57f4225e9..22dfd20ad 100644 --- a/media_kit_video/README.md +++ b/media_kit_video/README.md @@ -4,6 +4,99 @@ Native implementation for video playback in [package:media_kit](https://pub.dev/packages/media_kit). +## Native rendering-mode integration (Darwin) + +The registered plugin publishes its `MediaKitVideoPlugin` instance through +Flutter's plugin registry. Native integrations can use this typed API without +intercepting the plugin's method channel or replaying its create/dispose messages. +Call these APIs and consume their callbacks on the Flutter platform/main thread. + +```swift +import media_kit_video + +let video = registry.valuePublished(byPlugin: "MediaKitVideoPlugin") + as? MediaKitVideoPlugin + +// Also keep outputs created by playlist/episode changes on the software path. +video?.setSoftwareRenderingForNewOutputs(true) + +// Suspend this player's video track (vid=no) and confirm it before calling. +video?.setSoftwareRendering(handle: handle, enabled: true) { result in + switch result { + case .changed: + // Replacement context exists and its new texture ID has been emitted. + // Restore the saved vid after the Dart texture listener observes it. + break + case .unchanged: + // Already in this mode, or hardware rendering was disabled originally. + // Restore vid directly; no new texture ID will be emitted. + break + case .notReady: + // Another initialization/transition is pending. Restore vid directly, + // then retry after that output becomes ready; do not wait for a new ID + // inside this request. + break + case .failed: + // Restore vid directly as well. Do not leave the player disabled. + break + } +} +``` + +`enabled: false` restores the original output configuration, including the user's +hardware-rendering preference. It does not force GPU rendering for an originally +software output. The temporary override does not replace the mpv player or alter +its media, audio, playback position, decoder configuration, or shader options. +Software rendering does not apply GPU shader effects. A compatible copy-back +hardware decoder is needed when switching from GPU to software rendering; this +API deliberately does not change the player's decoder settings. + +The caller owns the video-track suspension/restoration transaction and must +serialize it per player, retain the exact player instance, and restore `vid` in +all completion paths. The plugin owns output configuration, the latest dimensions, +context/texture replacement, and disposal. Completion waits for initialization +and texture registration, not the first frame: a frame cannot be produced until +`vid` is restored. Overlapping initialization/transitions return `.notReady`; +unknown/disposed handles and outputs whose recovery failed return `.failed`. +Only `.notReady` permits a readiness-triggered retry. Retrying `.failed` on every +texture registration would loop when hardware is unavailable or the simulator +falls back to software. A target initialization failure attempts to recreate the +previous renderer before reporting `.failed`. If that recovery also fails, the +output is not reported as ready; the integration must surface a playback failure +or recreate the player. Disposal remains available and drains all resources. + +On iOS, request GPU restoration only when the application is active. The plugin +also checks application state before replacing an output, and guards the state +check plus GPU-context initialization in one platform-thread turn. Existing GPU +outputs gate resize/render work when the application resigns active; the lifecycle +callback waits for an in-flight GL section, and activation requests a fresh frame. +Property reads and platform notifications stay outside that render lock. If the app backgrounds during restoration, it keeps/recreates a +software output and reports `.failed`; the handle remains in +`softwareRenderingHandles` so restoration can be retried on the next activation. +Do not interpret `.failed` as proof that the old texture ID still exists. + +When leaving background playback, set the new-output default back to `false`, +then iterate `softwareRenderingHandles` and run the same suspend / set / restore +transaction with `enabled: false`. Changing the default alone does not replace +existing outputs. If a handle is still being initialized, retry after its normal +video-controller readiness signal. While background restoration is pending, +keep the new-output default enabled so later playlist/episode outputs stay safe. + +Run the deterministic native lifecycle tests with: + +```sh +media_kit_video/tool/rendering_mode_test/run.sh +``` + +These compile the real manager against controlled output doubles and cover +readiness, resize races, duplicate requests, initialization failures/rollback, +disposal, handle reuse, and backgrounding during GPU restoration. Additional +checks use the real output worker to verify delayed context readiness, safe +queued disposal, initialization failure, and the concurrent background render gate. They do not +replace device validation: the iOS simulator forces software rendering, so a +physical device is required to validate GPU -> software -> GPU, PiP background +frames, and shader restoration. + ## License Copyright © 2021 & onwards, Hitesh Kumar Saini <> diff --git a/media_kit_video/common/darwin/Classes/plugin/MediaKitVideoPlugin.swift b/media_kit_video/common/darwin/Classes/plugin/MediaKitVideoPlugin.swift index 25134af17..2709f12ce 100644 --- a/media_kit_video/common/darwin/Classes/plugin/MediaKitVideoPlugin.swift +++ b/media_kit_video/common/darwin/Classes/plugin/MediaKitVideoPlugin.swift @@ -31,6 +31,7 @@ public class MediaKitVideoPlugin: NSObject, FlutterPlugin { utils: utils ) registrar.addMethodCallDelegate(instance, channel: channel) + registrar.publish(instance) } private let channel: FlutterMethodChannel @@ -49,6 +50,30 @@ public class MediaKitVideoPlugin: NSObject, FlutterPlugin { self.utils = utils } + /// Native renderer API for integrations such as iOS sample-buffer PiP. + /// Must run on the platform thread. Suspend the player's video track before + /// calling and restore it after completion, even when the request fails. + /// `changed` means the replacement context is initialized and its Flutter + /// texture is registered; it does not wait for a frame while vid is disabled. + public func setSoftwareRendering( + handle: Int64, + enabled: Bool, + completion: @escaping (VideoOutputRenderingResult) -> Void + ) { + videoOutputManager.setSoftwareRendering(handle: handle, enabled: enabled, completion: completion) + } + + /// Override the renderer of future outputs without changing the original + /// configuration. Existing outputs still require setSoftwareRendering. + public func setSoftwareRenderingForNewOutputs(_ enabled: Bool) { + videoOutputManager.setSoftwareRenderingForNewOutputs(enabled) + } + + /// Handles that need their original GPU preference restored after PiP. + public var softwareRenderingHandles: [Int64] { + videoOutputManager.softwareRenderingHandles + } + public func handle( _ call: FlutterMethodCall, result: @escaping FlutterResult @@ -71,7 +96,7 @@ public class MediaKitVideoPlugin: NSObject, FlutterPlugin { private func handleCreateMethodCall( _ arguments: Any?, - _ result: FlutterResult + _ result: @escaping FlutterResult ) { let args = arguments as? [String: Any] guard let library = args?["libmpv"] as? String, @@ -104,10 +129,15 @@ public class MediaKitVideoPlugin: NSObject, FlutterPlugin { ], ] as [String: Any] ) + }, + completion: { ready in + if ready { + result(nil) + } else { + result(FlutterError(code: "video_output", message: "Could not initialize the video output.", details: nil)) + } } ) - - result(nil) } private func handleSetSizeMethodCall( diff --git a/media_kit_video/common/darwin/Classes/plugin/TextureSW.swift b/media_kit_video/common/darwin/Classes/plugin/TextureSW.swift index 302ba3f20..0d2a8db53 100644 --- a/media_kit_video/common/darwin/Classes/plugin/TextureSW.swift +++ b/media_kit_video/common/darwin/Classes/plugin/TextureSW.swift @@ -23,7 +23,7 @@ public class TextureSW: NSObject, FlutterTexture, ResizableTextureProtocol { skipCheckArgs: true ) - init( + init?( handle: OpaquePointer, updateCallback: @escaping UpdateCallback ) { @@ -32,9 +32,9 @@ public class TextureSW: NSObject, FlutterTexture, ResizableTextureProtocol { super.init() - DispatchQueue.main.async { - self.initMPV() - } + // Construct on the output worker so readiness means the context exists, + // and disposal can never overtake a deferred main-thread initialization. + guard self.initMPV() else { return nil } } deinit { @@ -55,7 +55,7 @@ public class TextureSW: NSObject, FlutterTexture, ResizableTextureProtocol { return Unmanaged.passRetained(textureContext!.pixelBuffer) } - private func initMPV() { + private func initMPV() -> Bool { let api = UnsafeMutableRawPointer( mutating: (MPV_RENDER_API_TYPE_SW as NSString).utf8String ) @@ -64,9 +64,11 @@ public class TextureSW: NSObject, FlutterTexture, ResizableTextureProtocol { mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil), ] - MPVHelpers.checkError( - media_kit_mpv_render_context_create(&renderContext, handle, ¶ms) - ) + let status = media_kit_mpv_render_context_create(&renderContext, handle, ¶ms) + guard status >= 0, renderContext != nil else { + NSLog("TextureSW: render context initialization failed (%d)", status) + return false + } media_kit_mpv_render_context_set_update_callback( renderContext, @@ -78,6 +80,7 @@ public class TextureSW: NSObject, FlutterTexture, ResizableTextureProtocol { }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) ) + return true } private func disposeMPV() { diff --git a/media_kit_video/common/darwin/Classes/plugin/VideoOutput.swift b/media_kit_video/common/darwin/Classes/plugin/VideoOutput.swift index d65f66f0f..53b962871 100644 --- a/media_kit_video/common/darwin/Classes/plugin/VideoOutput.swift +++ b/media_kit_video/common/darwin/Classes/plugin/VideoOutput.swift @@ -1,5 +1,8 @@ import CoreGraphics import Foundation +#if canImport(UIKit) + import UIKit +#endif #if canImport(Flutter) import Flutter @@ -7,6 +10,28 @@ import Foundation import FlutterMacOS #endif +// Closing the gate waits for an in-progress GL resize/render to finish. No +// platform dispatch or mpv property access is permitted inside runIfEnabled. +final class VideoOutputRenderGate { + private let lock = NSLock() + private var enabled = true + + func setEnabled(_ enabled: Bool) { + lock.lock() + self.enabled = enabled + lock.unlock() + } + + @discardableResult + func runIfEnabled(_ body: () -> Void) -> Bool { + lock.lock() + defer { lock.unlock() } + guard enabled else { return false } + body() + return true + } +} + // This class creates and manipulates the different types of FlutterTexture, // handles resizing, rendering calls, and notify Flutter when a new frame is // available to render. @@ -29,6 +54,8 @@ public class VideoOutput: NSObject { private let handle: OpaquePointer private let enableHardwareAcceleration: Bool + // Read on the platform thread only after initializationCompletion. + private(set) var usesHardwareAcceleration = false private let registry: FlutterTextureRegistry private let textureUpdateCallback: TextureUpdateCallback private let worker: Worker = .init() @@ -38,12 +65,15 @@ public class VideoOutput: NSObject { private var textureId: Int64 = -1 private var currentSize: CGSize = CGSize.zero private var disposed: Bool = false + private let renderGate = VideoOutputRenderGate() + private var lifecycleObservers: [NSObjectProtocol] = [] init( handle: Int64, configuration: VideoOutputConfiguration, registry: FlutterTextureRegistry, - textureUpdateCallback: @escaping TextureUpdateCallback + textureUpdateCallback: @escaping TextureUpdateCallback, + initializationCompletion: @escaping (Bool) -> Void = { _ in } ) { let handle = OpaquePointer(bitPattern: Int(handle)) assert(handle != nil, "handle casting") @@ -57,12 +87,30 @@ public class VideoOutput: NSObject { super.init() + #if canImport(UIKit) + renderGate.setEnabled(UIApplication.shared.applicationState == .active) + lifecycleObservers = [ + NotificationCenter.default.addObserver( + forName: UIApplication.willResignActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + self?.renderGate.setEnabled(false) + }, + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + self?.renderGate.setEnabled(true) + self?.updateCallback() + }, + ] + #endif + worker.enqueue { - self._init() + self._init(completion: initializationCompletion) } } deinit { + lifecycleObservers.forEach { NotificationCenter.default.removeObserver($0) } worker.cancel() disposed = true @@ -90,7 +138,7 @@ public class VideoOutput: NSObject { } } - private func _init() { + private func _init(completion: @escaping (Bool) -> Void) { let enableHardwareAcceleration = VideoOutput.isSimulator ? false : enableHardwareAcceleration @@ -109,23 +157,33 @@ public class VideoOutput: NSObject { } var hardwareTexture: ResizableTextureProtocol? if enableHardwareAcceleration { - hardwareTexture = TextureHW( - handle: handle, - updateCallback: update - ) + #if canImport(UIKit) + // Keep the state check and GL initialization in one platform-thread + // turn: an activation change must not slip between them while the + // worker is queued. Rendering remains on the worker, with the same + // EAGLContext made current by TextureHW for every render operation. + DispatchQueue.main.sync { + guard UIApplication.shared.applicationState == .active else { return } + renderGate.runIfEnabled { + hardwareTexture = TextureHW(handle: handle, updateCallback: update) + } + } + #else + hardwareTexture = TextureHW(handle: handle, updateCallback: update) + #endif } if let hardwareTexture = hardwareTexture { + usesHardwareAcceleration = true texture = SafeResizableTexture(hardwareTexture) } else { if enableHardwareAcceleration { NSLog("VideoOutput: hardware rendering unavailable; using software rendering") } - texture = SafeResizableTexture( - TextureSW( - handle: handle, - updateCallback: update - ) - ) + guard let softwareTexture = TextureSW(handle: handle, updateCallback: update) else { + DispatchQueue.main.async { completion(false) } + return + } + texture = SafeResizableTexture(softwareTexture) } DispatchQueue.main.sync { [weak self]() in @@ -133,6 +191,7 @@ public class VideoOutput: NSObject { return } that.registerTextureId() + completion(true) } } @@ -164,32 +223,34 @@ public class VideoOutput: NSObject { } private func _updateCallback() { - guard !disposed else { return } + guard !disposed, texture != nil else { return } let size = videoSize if size.width == 0 || size.height == 0 { return } - if currentSize != size { - currentSize = size - - texture.resize(size) - DispatchQueue.main.sync { [weak self] in - guard let that = self else { return } - // textureUpdateCallback must run on the main thread - that.textureUpdateCallback(that.textureId, size) + let resized = currentSize != size + let render = { + if resized { + self.currentSize = size + self.texture.resize(size) } + self.texture.render(size) } - - if disposed { - return + if usesHardwareAcceleration { + // The main-thread resign-active handler waits for this short GL section. + // Keep all main.sync notifications and mpv property reads outside it. + guard renderGate.runIfEnabled(render) else { return } + } else { + render() } - - texture.render(size) DispatchQueue.main.sync { [weak self] in guard let that = self else { return } - // Textures must be marked as available from the main thread + if resized { + that.textureUpdateCallback(that.textureId, size) + } + // Textures must be marked as available from the main thread. that.registry.textureFrameAvailable(that.textureId) } } diff --git a/media_kit_video/common/darwin/Classes/plugin/VideoOutputManager.swift b/media_kit_video/common/darwin/Classes/plugin/VideoOutputManager.swift index 9ba9afa8e..175240be0 100644 --- a/media_kit_video/common/darwin/Classes/plugin/VideoOutputManager.swift +++ b/media_kit_video/common/darwin/Classes/plugin/VideoOutputManager.swift @@ -1,56 +1,249 @@ +import Foundation +#if canImport(UIKit) + import UIKit +#endif + #if canImport(Flutter) import Flutter #elseif canImport(FlutterMacOS) import FlutterMacOS #endif +/// The result of a native rendering-mode request. All callbacks run on the +/// platform thread. A changed output has an initialized context and texture ID. +public enum VideoOutputRenderingResult { + case unchanged + case changed + /// Initialization or another rendering transition is still in progress. + case notReady + case failed +} + public class VideoOutputManager: NSObject { + private final class Entry { + let configuration: VideoOutputConfiguration + let textureUpdateCallback: VideoOutput.TextureUpdateCallback + var width: Int64? + var height: Int64? + var softwareRendering: Bool + var output: VideoOutput? + var busy = true + var ready = false + var destroyed = false + var destroyCompletions: [() -> Void] = [] + + init(configuration: VideoOutputConfiguration, + softwareRendering: Bool, + textureUpdateCallback: @escaping VideoOutput.TextureUpdateCallback) { + self.configuration = configuration + self.textureUpdateCallback = textureUpdateCallback + width = configuration.width + height = configuration.height + self.softwareRendering = softwareRendering + } + + func configuration(softwareRendering: Bool) -> VideoOutputConfiguration { + VideoOutputConfiguration( + width: width, + height: height, + enableHardwareAcceleration: configuration.enableHardwareAcceleration && !softwareRendering + ) + } + } + private let registry: FlutterTextureRegistry - private var videoOutputs = [Int64: VideoOutput]() + private var videoOutputs = [Int64: Entry]() + private var softwareRenderingForNewOutputs = false + private let canUseHardwareRendering: () -> Bool - init(registry: FlutterTextureRegistry) { + init(registry: FlutterTextureRegistry, canUseHardwareRendering: (() -> Bool)? = nil) { self.registry = registry + self.canUseHardwareRendering = canUseHardwareRendering ?? { + #if canImport(UIKit) + return UIApplication.shared.applicationState == .active + #else + return true + #endif + } + } + + /// Applies only to subsequently created outputs. Existing outputs must be + /// changed with setSoftwareRendering after their video tracks are suspended. + public func setSoftwareRenderingForNewOutputs(_ enabled: Bool) { + assert(Thread.isMainThread) + softwareRenderingForNewOutputs = enabled + } + + /// Outputs whose original GPU preference is currently overridden. Includes + /// outputs being initialized so callers can retry restoration when ready. + public var softwareRenderingHandles: [Int64] { + assert(Thread.isMainThread) + return videoOutputs.compactMap { handle, entry in + !entry.destroyed && entry.configuration.enableHardwareAcceleration && entry.softwareRendering + ? handle : nil + } } public func create( handle: Int64, configuration: VideoOutputConfiguration, - textureUpdateCallback: @escaping VideoOutput.TextureUpdateCallback + textureUpdateCallback: @escaping VideoOutput.TextureUpdateCallback, + completion: @escaping (Bool) -> Void = { _ in } ) { - let videoOutput = VideoOutput( - handle: handle, - configuration: configuration, - registry: self.registry, - textureUpdateCallback: textureUpdateCallback - ) + assert(Thread.isMainThread) + // A second context for the same core is invalid, including while its + // previous output is still being asynchronously destroyed. + guard videoOutputs[handle] == nil else { completion(false); return } + let entry = Entry(configuration: configuration, + softwareRendering: softwareRenderingForNewOutputs, + textureUpdateCallback: textureUpdateCallback) + videoOutputs[handle] = entry + makeOutput(handle: handle, entry: entry, softwareRendering: entry.softwareRendering) { ready in + entry.busy = false + guard ready && !entry.destroyed else { + entry.destroyed = true + // Finish disposal before reporting failure, allowing the caller to + // release the player's mpv core as soon as its Create future fails. + entry.destroyCompletions.append { completion(false) } + self.finishDestroy(handle: handle, entry: entry) + return + } + entry.ready = true + entry.softwareRendering = entry.configuration.enableHardwareAcceleration && + !(entry.output?.usesHardwareAcceleration ?? false) + completion(true) + } + } - self.videoOutputs[handle] = videoOutput + public func setSize(handle: Int64, width: Int64?, height: Int64?) { + assert(Thread.isMainThread) + guard let entry = videoOutputs[handle], !entry.destroyed else { return } + entry.width = width + entry.height = height + entry.output?.setSize(width: width, height: height) } - public func setSize( + /// Caller must suspend the video's `vid` before this call, and restore it + /// after completion (including failure). The player and decoder are retained. + /// Requests overlapping another initialization/transition return notReady; + /// unknown, disposed, or unsuccessfully recovered outputs return failed. + public func setSoftwareRendering( handle: Int64, - width: Int64?, - height: Int64? + enabled: Bool, + completion: @escaping (VideoOutputRenderingResult) -> Void ) { - let videoOutput = self.videoOutputs[handle] - if videoOutput == nil { + assert(Thread.isMainThread) + guard let entry = videoOutputs[handle], !entry.destroyed else { completion(.failed); return } + guard !entry.busy else { completion(.notReady); return } + guard entry.ready, let output = entry.output else { completion(.failed); return } + let previous = entry.softwareRendering + // If GPU was disabled by the user, both modes have the same renderer. + guard entry.configuration.enableHardwareAcceleration && previous != enabled else { + entry.softwareRendering = enabled + completion(.unchanged) return } + // A restoration requested in background must preserve the existing CPU + // context. The caller can retry once the application becomes active. + guard enabled || self.canUseHardwareRendering() else { completion(.failed); return } + entry.busy = true + entry.output = nil + output.dispose { + guard !entry.destroyed else { + self.finishTransition(handle: handle, entry: entry, result: .failed, completion: completion) + return + } + let targetSoftware = enabled || !self.canUseHardwareRendering() + self.makeOutput(handle: handle, entry: entry, softwareRendering: targetSoftware) { ready in + if ready && !entry.destroyed { + entry.softwareRendering = entry.configuration.enableHardwareAcceleration && + !(entry.output?.usesHardwareAcceleration ?? false) + entry.ready = true + let result: VideoOutputRenderingResult = entry.softwareRendering == enabled ? .changed : .failed + self.finishTransition(handle: handle, entry: entry, result: result, completion: completion) + return + } + guard !entry.destroyed else { + self.finishTransition(handle: handle, entry: entry, result: .failed, completion: completion) + return + } + // Try to recover the prior renderer before asking the caller to + // restore vid. A failed request must not leave a discarded context. + let failedOutput = entry.output + entry.output = nil + failedOutput?.dispose { + guard !entry.destroyed else { + self.finishTransition(handle: handle, entry: entry, result: .failed, completion: completion) + return + } + let rollbackSoftware = previous || !self.canUseHardwareRendering() + self.makeOutput(handle: handle, entry: entry, softwareRendering: rollbackSoftware) { recovered in + entry.ready = recovered + entry.softwareRendering = entry.configuration.enableHardwareAcceleration && + !(entry.output?.usesHardwareAcceleration ?? false) + self.finishTransition(handle: handle, entry: entry, result: .failed, completion: completion) + } + } + } + } + } - videoOutput!.setSize( - width: width, - height: height + private func makeOutput(handle: Int64, entry: Entry, softwareRendering: Bool, + completion: @escaping (Bool) -> Void) { + entry.output = VideoOutput( + handle: handle, + configuration: entry.configuration(softwareRendering: softwareRendering), + registry: registry, + textureUpdateCallback: { [weak entry] textureId, size in + guard let entry = entry, !entry.destroyed else { return } + entry.textureUpdateCallback(textureId, size) + }, + initializationCompletion: { ready in + // A size update may have arrived while the old output was draining. + entry.output?.setSize(width: entry.width, height: entry.height) + completion(ready) + } ) } - public func destroy( - handle: Int64, - completion: @escaping () -> Void - ) { - guard let videoOutput = videoOutputs.removeValue(forKey: handle) else { - completion() - return + private func finishTransition(handle: Int64, entry: Entry, + result: VideoOutputRenderingResult, + completion: @escaping (VideoOutputRenderingResult) -> Void) { + entry.busy = false + if entry.destroyed { + // Drain a replacement that finished creating during disposal before + // allowing either caller to release or reuse the player's handle. + entry.destroyCompletions.append { completion(.failed) } + finishDestroy(handle: handle, entry: entry) + } else { + completion(result) + } + } + + public func destroy(handle: Int64, completion: @escaping () -> Void) { + assert(Thread.isMainThread) + guard let entry = videoOutputs[handle] else { completion(); return } + entry.destroyed = true + entry.destroyCompletions.append(completion) + finishDestroy(handle: handle, entry: entry) + } + + private func finishDestroy(handle: Int64, entry: Entry) { + guard entry.destroyed && !entry.busy else { return } + entry.busy = true + let finish = { + if self.videoOutputs[handle] === entry { + self.videoOutputs.removeValue(forKey: handle) + } + let completions = entry.destroyCompletions + entry.destroyCompletions.removeAll() + completions.forEach { $0() } + } + if let output = entry.output { + entry.output = nil + output.dispose(completion: finish) + } else { + finish() } - videoOutput.dispose(completion: completion) } } diff --git a/media_kit_video/ios/media_kit_video/Sources/media_kit_video/plugin/TextureHW.swift b/media_kit_video/ios/media_kit_video/Sources/media_kit_video/plugin/TextureHW.swift index 23addbf19..7d6f00387 100644 --- a/media_kit_video/ios/media_kit_video/Sources/media_kit_video/plugin/TextureHW.swift +++ b/media_kit_video/ios/media_kit_video/Sources/media_kit_video/plugin/TextureHW.swift @@ -18,18 +18,20 @@ public class TextureHW: NSObject, FlutterTexture, ResizableTextureProtocol { skipCheckArgs: true ) - init( + init?( handle: OpaquePointer, updateCallback: @escaping UpdateCallback ) { + guard let context = OpenGLESHelpers.tryCreateContext(), + let textureCache = OpenGLESHelpers.tryCreateTextureCache(context) else { return nil } self.handle = handle self.updateCallback = updateCallback - self.context = OpenGLESHelpers.createContext() - self.textureCache = OpenGLESHelpers.createTextureCache(context) + self.context = context + self.textureCache = textureCache super.init() - self.initMPV() + guard self.initMPV() else { return nil } } deinit { @@ -57,7 +59,7 @@ public class TextureHW: NSObject, FlutterTexture, ResizableTextureProtocol { disposeMPV() } - private func initMPV() { + private func initMPV() -> Bool { EAGLContext.setCurrent(context) defer { OpenGLESHelpers.checkError("initMPV") @@ -89,9 +91,11 @@ public class TextureHW: NSObject, FlutterTexture, ResizableTextureProtocol { ] } - MPVHelpers.checkError( - media_kit_mpv_render_context_create(&renderContext, handle, ¶ms) - ) + let status = media_kit_mpv_render_context_create(&renderContext, handle, ¶ms) + guard status >= 0, renderContext != nil else { + NSLog("TextureHW: render context initialization failed (%d)", status) + return false + } media_kit_mpv_render_context_set_update_callback( renderContext, @@ -103,6 +107,7 @@ public class TextureHW: NSObject, FlutterTexture, ResizableTextureProtocol { }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) ) + return true } private func disposeMPV() { diff --git a/media_kit_video/ios/media_kit_video/Sources/media_kit_video/plugin/gles/OpenGLESHelpers.swift b/media_kit_video/ios/media_kit_video/Sources/media_kit_video/plugin/gles/OpenGLESHelpers.swift index 40adef0ac..b772ffd79 100644 --- a/media_kit_video/ios/media_kit_video/Sources/media_kit_video/plugin/gles/OpenGLESHelpers.swift +++ b/media_kit_video/ios/media_kit_video/Sources/media_kit_video/plugin/gles/OpenGLESHelpers.swift @@ -3,13 +3,20 @@ import UIKit public class OpenGLESHelpers { static public func createContext() -> EAGLContext { - let context = EAGLContext(api: .openGLES3) - return context! + tryCreateContext()! } - static public func createTextureCache( + static func tryCreateContext() -> EAGLContext? { + EAGLContext(api: .openGLES3) + } + + static public func createTextureCache(_ context: EAGLContext) -> CVOpenGLESTextureCache { + tryCreateTextureCache(context)! + } + + static func tryCreateTextureCache( _ context: EAGLContext - ) -> CVOpenGLESTextureCache { + ) -> CVOpenGLESTextureCache? { var textureCache: CVOpenGLESTextureCache? let cvret: CVReturn = CVOpenGLESTextureCacheCreate( @@ -19,9 +26,8 @@ public class OpenGLESHelpers { nil, &textureCache ) - assert(cvret == kCVReturnSuccess, "CVOpenGLESTextureCacheCreate") - - return textureCache! + guard cvret == kCVReturnSuccess else { return nil } + return textureCache } static public func createPixelBuffer(_ size: CGSize) -> CVPixelBuffer { diff --git a/media_kit_video/macos/media_kit_video/Sources/media_kit_video/plugin/TextureHW.swift b/media_kit_video/macos/media_kit_video/Sources/media_kit_video/plugin/TextureHW.swift index 6bfff1dfe..6d64e1b9f 100644 --- a/media_kit_video/macos/media_kit_video/Sources/media_kit_video/plugin/TextureHW.swift +++ b/media_kit_video/macos/media_kit_video/Sources/media_kit_video/plugin/TextureHW.swift @@ -42,7 +42,7 @@ public class TextureHW: NSObject, FlutterTexture, ResizableTextureProtocol { super.init() - self.initMPV() + guard self.initMPV() else { return nil } } deinit { @@ -71,7 +71,7 @@ public class TextureHW: NSObject, FlutterTexture, ResizableTextureProtocol { disposeMPV() } - private func initMPV() { + private func initMPV() -> Bool { CGLSetCurrentContext(context) defer { OpenGLHelpers.checkError("initMPV") @@ -103,9 +103,11 @@ public class TextureHW: NSObject, FlutterTexture, ResizableTextureProtocol { ] } - MPVHelpers.checkError( - media_kit_mpv_render_context_create(&renderContext, handle, ¶ms) - ) + let status = media_kit_mpv_render_context_create(&renderContext, handle, ¶ms) + guard status >= 0, renderContext != nil else { + NSLog("TextureHW: render context initialization failed (%d)", status) + return false + } media_kit_mpv_render_context_set_update_callback( renderContext, @@ -117,6 +119,7 @@ public class TextureHW: NSObject, FlutterTexture, ResizableTextureProtocol { }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) ) + return true } private func disposeMPV() { diff --git a/media_kit_video/tool/rendering_mode_test/main.swift b/media_kit_video/tool/rendering_mode_test/main.swift new file mode 100644 index 000000000..36b81e5e1 --- /dev/null +++ b/media_kit_video/tool/rendering_mode_test/main.swift @@ -0,0 +1,266 @@ +import Foundation +import CoreGraphics + +// Deterministic doubles exercise the real VideoOutputManager without Flutter, +// a GPU, or libmpv. Each async boundary is advanced explicitly by a test. +public protocol FlutterTextureRegistry {} +final class Registry: FlutterTextureRegistry {} + +public final class VideoOutput { + public typealias TextureUpdateCallback = (Int64, CGSize) -> Void + static var instances: [VideoOutput] = [] + let configuration: VideoOutputConfiguration + let textureUpdateCallback: TextureUpdateCallback + let initializationCompletion: (Bool) -> Void + var pendingDisposals: [() -> Void] = [] + var size: (Int64?, Int64?) + var disposeCount = 0 + var usesHardwareAcceleration: Bool { configuration.enableHardwareAcceleration } + + init(handle: Int64, configuration: VideoOutputConfiguration, + registry: FlutterTextureRegistry, + textureUpdateCallback: @escaping TextureUpdateCallback, + initializationCompletion: @escaping (Bool) -> Void) { + self.configuration = configuration + self.textureUpdateCallback = textureUpdateCallback + self.initializationCompletion = initializationCompletion + size = (configuration.width, configuration.height) + Self.instances.append(self) + } + + func initialize(_ ready: Bool = true) { + if ready { textureUpdateCallback(Int64(Self.instances.firstIndex { $0 === self }! + 1), .zero) } + initializationCompletion(ready) + } + + public func setSize(width: Int64?, height: Int64?) { size = (width, height) } + public func dispose(completion: @escaping () -> Void) { + disposeCount += 1 + pendingDisposals.append(completion) + } + func finishDisposal() { + let callbacks = pendingDisposals + pendingDisposals.removeAll() + callbacks.forEach { $0() } + } +} + +func check(_ condition: @autoclosure () -> Bool, _ message: String) { + guard condition() else { fatalError(message) } +} + +func config(_ hardware: Bool = true) -> VideoOutputConfiguration { + VideoOutputConfiguration(width: 640, height: 360, enableHardwareAcceleration: hardware) +} + +func create(_ manager: VideoOutputManager, handle: Int64 = 1, hardware: Bool = true) -> VideoOutput { + var ready: Bool? + manager.create(handle: handle, configuration: config(hardware), textureUpdateCallback: { _, _ in }, + completion: { ready = $0 }) + let output = VideoOutput.instances.last! + check(ready == nil, "Create must wait for context initialization") + output.initialize() + check(ready == true, "Create should complete after context and texture registration") + return output +} + +var passed = 0 +func test(_ name: String, _ body: () -> Void) { + VideoOutput.instances.removeAll() + body() + passed += 1 + print("PASS \(name)") +} + +test("GPU -> software -> configured, including resize while old output drains") { + let manager = VideoOutputManager(registry: Registry()) + let original = create(manager) + var result: VideoOutputRenderingResult? + manager.setSoftwareRendering(handle: 1, enabled: true) { result = $0 } + check(result == nil && VideoOutput.instances.count == 1, "Wait for old context disposal") + manager.setSize(handle: 1, width: 1280, height: 720) + original.finishDisposal() + let software = VideoOutput.instances.last! + check(!software.configuration.enableHardwareAcceleration, "Replacement must be software") + check(software.configuration.width == 1280 && software.configuration.height == 720, "Keep new dimensions") + check(result == nil, "Wait for replacement context initialization") + software.initialize() + check(result == .changed && manager.softwareRenderingHandles == [1], "Report changed and track override") + manager.setSoftwareRendering(handle: 1, enabled: true) { result = $0 } + check(result == .unchanged && VideoOutput.instances.count == 2, "Repeated mode is a no-op") + manager.setSoftwareRendering(handle: 1, enabled: false) { result = $0 } + software.finishDisposal() + let restored = VideoOutput.instances.last! + check(restored.configuration.enableHardwareAcceleration, "Restore original GPU preference") + restored.initialize() + check(result == .changed && manager.softwareRenderingHandles.isEmpty, "Clear successful override") +} + +test("User CPU preference is preserved and new-output override covers new handles") { + let manager = VideoOutputManager(registry: Registry()) + manager.setSoftwareRenderingForNewOutputs(true) + let originalCPU = create(manager, handle: 1, hardware: false) + let overridden = create(manager, handle: 2) + check(!overridden.configuration.enableHardwareAcceleration, "New handles inherit software override") + check(manager.softwareRenderingHandles == [2], "Only original GPU preferences need restoration") + var result: VideoOutputRenderingResult? + manager.setSoftwareRendering(handle: 1, enabled: false) { result = $0 } + check(result == .unchanged && originalCPU.disposeCount == 0, "Original CPU remains CPU") + manager.setSoftwareRenderingForNewOutputs(false) + check(create(manager, handle: 3).configuration.enableHardwareAcceleration, "Clearing default affects future handles") + check(manager.softwareRenderingHandles == [2], "Existing override still needs explicit suspended transition") +} + +test("Destroy during old-output disposal prevents replacement and delays completion") { + let manager = VideoOutputManager(registry: Registry()) + let original = create(manager) + var result: VideoOutputRenderingResult? + var destroyed = 0 + manager.setSoftwareRendering(handle: 1, enabled: true) { result = $0 } + manager.destroy(handle: 1) { destroyed += 1 } + manager.destroy(handle: 1) { destroyed += 1 } + check(destroyed == 0 && result == nil, "Do not release core while context drains") + original.finishDisposal() + check(result == .failed && destroyed == 2, "Complete all callers once") + check(VideoOutput.instances.count == 1 && original.disposeCount == 1, "Do not create or double-dispose") + _ = create(manager) +} + +test("Destroy during replacement initialization drains new output before core release") { + let manager = VideoOutputManager(registry: Registry()) + let original = create(manager) + var result: VideoOutputRenderingResult? + var destroyed = false + manager.setSoftwareRendering(handle: 1, enabled: true) { result = $0 } + original.finishDisposal() + let replacement = VideoOutput.instances.last! + manager.destroy(handle: 1) { destroyed = true } + replacement.initialize() + check(!destroyed && result == nil && replacement.disposeCount == 1, "Dispose initialized replacement before callbacks") + replacement.finishDisposal() + check(destroyed && result == .failed, "Drain replacement before reporting disposed") +} + +test("Failed target rolls back before reporting failure") { + let manager = VideoOutputManager(registry: Registry()) + let original = create(manager) + var result: VideoOutputRenderingResult? + manager.setSoftwareRendering(handle: 1, enabled: true) { result = $0 } + original.finishDisposal() + let failed = VideoOutput.instances.last! + failed.initialize(false) + check(result == nil && failed.disposeCount == 1, "Drain failed target") + failed.finishDisposal() + let rollback = VideoOutput.instances.last! + check(rollback.configuration.enableHardwareAcceleration && result == nil, "Rebuild prior mode first") + rollback.initialize() + check(result == .failed && manager.softwareRenderingHandles.isEmpty, "Original renderer ready before failed callback") +} + +test("Create failure and destroy during initialization never release core early") { + let manager = VideoOutputManager(registry: Registry()) + var created: Bool? + var destroyed = false + manager.create(handle: 1, configuration: config(), textureUpdateCallback: { _, _ in }, + completion: { created = $0 }) + let initializing = VideoOutput.instances.last! + manager.destroy(handle: 1) { destroyed = true } + initializing.initialize(false) + check(created == nil && !destroyed, "Wait for failed context cleanup") + initializing.finishDisposal() + check(created == false && destroyed, "Fail once cleanup completes") + _ = create(manager) +} + +test("Overlapping operations report notReady without mutating the active output") { + let manager = VideoOutputManager(registry: Registry()) + let original = create(manager) + var duplicate: Bool? + manager.create(handle: 1, configuration: config(), textureUpdateCallback: { _, _ in }, + completion: { duplicate = $0 }) + check(duplicate == false && VideoOutput.instances.count == 1, "Reject duplicate context") + manager.setSoftwareRendering(handle: 1, enabled: true) { _ in } + var overlapping: VideoOutputRenderingResult? + manager.setSoftwareRendering(handle: 1, enabled: false) { overlapping = $0 } + check(overlapping == .notReady, "Report transient overlap before mutation") + original.finishDisposal() + VideoOutput.instances.last!.initialize() + check(manager.softwareRenderingHandles == [1], "Rejected switch did not overwrite mode") +} + +test("Background transition while disposal drains builds CPU and keeps restoration pending") { + var foreground = true + let manager = VideoOutputManager(registry: Registry(), canUseHardwareRendering: { foreground }) + manager.setSoftwareRenderingForNewOutputs(true) + let software = create(manager) + var result: VideoOutputRenderingResult? + manager.setSoftwareRendering(handle: 1, enabled: false) { result = $0 } + foreground = false + software.finishDisposal() + let replacement = VideoOutput.instances.last! + check(!replacement.configuration.enableHardwareAcceleration, "Do not construct GL after backgrounding") + replacement.initialize() + check(result == .failed && manager.softwareRenderingHandles == [1], "Keep software override for next foreground retry") + let count = VideoOutput.instances.count + manager.setSoftwareRendering(handle: 1, enabled: false) { result = $0 } + check(result == .failed && count == VideoOutput.instances.count, "Already-background restoration retains existing output") + foreground = true + manager.setSoftwareRendering(handle: 1, enabled: false) { result = $0 } + replacement.finishDisposal() + VideoOutput.instances.last!.initialize() + check(result == .changed && manager.softwareRenderingHandles.isEmpty, "A later foreground restoration succeeds") +} + +test("Failed target and failed rollback cannot masquerade as a ready unchanged output") { + let manager = VideoOutputManager(registry: Registry()) + let original = create(manager) + var result: VideoOutputRenderingResult? + manager.setSoftwareRendering(handle: 1, enabled: true) { result = $0 } + original.finishDisposal() + let failed = VideoOutput.instances.last! + failed.initialize(false) + failed.finishDisposal() + let rollback = VideoOutput.instances.last! + rollback.initialize(false) + check(result == .failed, "Both creation failures are reported") + manager.setSoftwareRendering(handle: 1, enabled: false) { result = $0 } + check(result == .failed, "Uninitialized output must never report unchanged") + var disposed = false + manager.destroy(handle: 1) { disposed = true } + check(!disposed, "Dispose still drains failed rollback output") + rollback.finishDisposal() + check(disposed, "Failed output remains safely disposable") +} + +test("Old output notifications are ignored after dispose and handle reuse") { + let manager = VideoOutputManager(registry: Registry()) + var oldNotifications = 0 + manager.create(handle: 1, configuration: config(), textureUpdateCallback: { _, _ in oldNotifications += 1 }) + let original = VideoOutput.instances.last! + original.initialize() + manager.destroy(handle: 1) {} + original.textureUpdateCallback(99, .zero) + check(oldNotifications == 1, "Suppress callbacks as soon as disposal starts") + original.finishDisposal() + _ = create(manager) + original.textureUpdateCallback(100, .zero) + check(oldNotifications == 1, "Old instance cannot notify a replacement handle") +} + +test("Initialization is temporarily notReady while missing or disposed handles fail") { + let manager = VideoOutputManager(registry: Registry()) + var result: VideoOutputRenderingResult? + manager.setSoftwareRendering(handle: 1, enabled: true) { result = $0 } + check(result == .failed, "Missing handles must not be retried as initialization") + manager.create(handle: 1, configuration: config(), textureUpdateCallback: { _, _ in }) + let initializing = VideoOutput.instances.last! + manager.setSoftwareRendering(handle: 1, enabled: true) { result = $0 } + check(result == .notReady, "Pending initialization is a retryable state") + manager.destroy(handle: 1) {} + manager.setSoftwareRendering(handle: 1, enabled: true) { result = $0 } + check(result == .failed, "A disposed handle is permanent even while cleanup is busy") + initializing.initialize() + initializing.finishDisposal() +} + +print("\(passed) rendering lifecycle tests passed") diff --git a/media_kit_video/tool/rendering_mode_test/output.swift b/media_kit_video/tool/rendering_mode_test/output.swift new file mode 100644 index 000000000..9b8905b69 --- /dev/null +++ b/media_kit_video/tool/rendering_mode_test/output.swift @@ -0,0 +1,149 @@ +import Foundation +import CoreGraphics + +public protocol FlutterTexture: AnyObject {} +public protocol FlutterTextureRegistry { + func register(_ texture: FlutterTexture) -> Int64 + func unregisterTexture(_ id: Int64) + func textureFrameAvailable(_ id: Int64) +} +public protocol ResizableTextureProtocol: FlutterTexture { + func dispose() + func resize(_ size: CGSize) + func render(_ size: CGSize) +} + +// Keep the real worker, VideoOutput initialization, and registration/disposal +// order. Only the platform textures and Flutter registry are replaced. +final class Control { + static let lock = NSLock() + static var gate: DispatchSemaphore? + static var entered = false + static var fail = false + static var disposals = 0 + static func locked(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } +} +final class TextureSW: ResizableTextureProtocol { + init?(handle: OpaquePointer, updateCallback: @escaping () -> Void) { + let gate = Control.locked { () -> DispatchSemaphore? in + Control.entered = true + return Control.gate + } + gate?.wait() + if Control.locked({ Control.fail }) { return nil } + } + func dispose() { Control.locked { Control.disposals += 1 } } + func resize(_ size: CGSize) {} + func render(_ size: CGSize) {} +} +final class TextureHW: ResizableTextureProtocol { + init?(handle: OpaquePointer, updateCallback: @escaping () -> Void) { return nil } + func dispose() {} + func resize(_ size: CGSize) {} + func render(_ size: CGSize) {} +} +final class SafeResizableTexture: ResizableTextureProtocol { + let child: ResizableTextureProtocol + init(_ child: ResizableTextureProtocol) { self.child = child } + func dispose() { child.dispose() } + func resize(_ size: CGSize) { child.resize(size) } + func render(_ size: CGSize) { child.render(size) } +} +struct Params { let rotate: Int64 = 0; let dw: Int64 = 0; let dh: Int64 = 0 } +enum MPVHelpers { static func getVideoOutParams(_ handle: OpaquePointer) -> Params { Params() } } +final class Registry: FlutterTextureRegistry { + var registrations = 0 + var unregistrations = 0 + func register(_ texture: FlutterTexture) -> Int64 { + precondition(Thread.isMainThread) + registrations += 1 + return Int64(registrations) + } + func unregisterTexture(_ id: Int64) { + precondition(Thread.isMainThread) + unregistrations += 1 + } + func textureFrameAvailable(_ id: Int64) { precondition(Thread.isMainThread) } +} + +func check(_ condition: @autoclosure () -> Bool, _ message: String) { + guard condition() else { fatalError(message) } +} +func pump(until condition: () -> Bool) { + let deadline = Date().addingTimeInterval(3) + while !condition() && Date() < deadline { + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.005)) + } + check(condition(), "Timed out waiting for worker/platform callback") +} + +@main enum Tests { + static func main() { + let renderGate = VideoOutputRenderGate() + let rendering = DispatchSemaphore(value: 0) + let releaseRender = DispatchSemaphore(value: 0) + let disabling = DispatchSemaphore(value: 0) + let disabled = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + renderGate.runIfEnabled { + rendering.signal() + releaseRender.wait() + } + } + check(rendering.wait(timeout: .now() + 2) == .success, "Rendering should enter the open gate") + DispatchQueue.global().async { + disabling.signal() + renderGate.setEnabled(false) + disabled.signal() + } + check(disabling.wait(timeout: .now() + 2) == .success, "Disable should start") + check(disabled.wait(timeout: .now() + 0.02) == .timedOut, "Disable must wait for in-flight GL work") + releaseRender.signal() + check(disabled.wait(timeout: .now() + 2) == .success, "Disable should finish when GL work drains") + check(!renderGate.runIfEnabled { fatalError("GL must not begin after disable completes") }, "Disabled gate rejects GL") + renderGate.setEnabled(true) + check(renderGate.runIfEnabled {}, "Foreground activation should reopen the gate") + print("PASS rendering gate drains in-flight work and blocks new background GL") + + let configuration = VideoOutputConfiguration(width: 640, height: 360, enableHardwareAcceleration: false) + let registry = Registry() + let gate = DispatchSemaphore(value: 0) + Control.locked { Control.gate = gate } + var ready: Bool? + var resized = false + var disposed = false + let output = VideoOutput(handle: 1, configuration: configuration, registry: registry, + textureUpdateCallback: { _, _ in resized = true }, initializationCompletion: { + check(Thread.isMainThread, "Initialization completion must use platform thread") + check(resized, "Texture ID must be emitted before successful readiness") + ready = $0 + }) + pump { Control.locked { Control.entered } } + check(ready == nil && registry.registrations == 0, "Do not register/complete before context initialization") + output.dispose { disposed = true } + check(!disposed, "Disposal must wait for queued initialization") + gate.signal() + pump { disposed } + check(ready == true && registry.registrations == 1 && registry.unregistrations == 1, + "Initialization and disposal must each finish once in order") + check(Control.locked { Control.disposals } == 1, "Render context must be disposed once") + print("PASS real VideoOutput waits for initialization and drains queued disposal") + + Control.locked { Control.gate = nil; Control.fail = true } + var failedReady: Bool? + var failedDisposed = false + let failed = VideoOutput(handle: 2, configuration: configuration, registry: registry, + textureUpdateCallback: { _, _ in fatalError("Failed context cannot publish a texture") }, + initializationCompletion: { failedReady = $0 }) + pump { failedReady != nil } + check(failedReady == false && registry.registrations == 1, "Creation failure is recoverable and registers no texture") + failed.dispose { failedDisposed = true } + pump { failedDisposed } + check(registry.unregistrations == 1, "Failed output has no registered texture to unregister") + print("PASS real VideoOutput reports context failure and remains disposable") + } +} diff --git a/media_kit_video/tool/rendering_mode_test/run.sh b/media_kit_video/tool/rendering_mode_test/run.sh new file mode 100755 index 000000000..1136830cf --- /dev/null +++ b/media_kit_video/tool/rendering_mode_test/run.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +script_dir="$(cd "$(dirname "$0")" && pwd)" +plugin_dir="$script_dir/../../common/darwin/Classes/plugin" +build_dir="$(mktemp -d "${TMPDIR:-/tmp}/media-kit-rendering-test.XXXXXX")" +trap 'rm -rf "$build_dir"' EXIT +xcrun swiftc -module-cache-path "$build_dir/module-cache" \ + "$plugin_dir/VideoOutputConfiguration.swift" \ + "$plugin_dir/VideoOutputManager.swift" \ + "$script_dir/main.swift" -o "$build_dir/rendering-tests" +"$build_dir/rendering-tests" + +xcrun swiftc -parse-as-library -module-cache-path "$build_dir/module-cache" \ + "$plugin_dir/VideoOutputConfiguration.swift" \ + "$plugin_dir/Worker.swift" \ + "$plugin_dir/VideoOutput.swift" \ + "$script_dir/output.swift" -o "$build_dir/output-tests" +"$build_dir/output-tests"