implement video encoding and decoding functionality with screen capture support
This commit is contained in:
@@ -25,11 +25,16 @@ class AppDelegate: FlutterAppDelegate {
|
||||
private var captureWidth = 1280
|
||||
private var captureHeight = 720
|
||||
|
||||
// viewer decoder sessions keyed by texture id
|
||||
private var decoders: [Int64: VideoDecoderSession] = [:]
|
||||
private var textureRegistry: FlutterTextureRegistry?
|
||||
|
||||
override func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
guard let window = mainFlutterWindow,
|
||||
let ctrl = window.contentViewController as? FlutterViewController else { return }
|
||||
|
||||
let messenger = ctrl.engine.binaryMessenger
|
||||
textureRegistry = ctrl.engine.textureRegistry
|
||||
|
||||
inputChannel = FlutterMethodChannel(name: "com.pulsar/input", binaryMessenger: messenger)
|
||||
inputChannel?.setMethodCallHandler { [weak self] call, result in
|
||||
@@ -58,6 +63,40 @@ class AppDelegate: FlutterAppDelegate {
|
||||
case "forceKeyframe":
|
||||
self.requestKeyframe()
|
||||
result(nil)
|
||||
|
||||
case "createDecoder":
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let spsPpsTyped = args["spsPps"] as? FlutterStandardTypedData,
|
||||
let registry = self.textureRegistry else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: nil, details: nil))
|
||||
return
|
||||
}
|
||||
if let decoder = VideoDecoderSession(spsPps: spsPpsTyped.data, textureRegistry: registry) {
|
||||
self.decoders[decoder.textureId] = decoder
|
||||
result(["textureId": decoder.textureId])
|
||||
} else {
|
||||
result(FlutterError(code: "DECODER_FAILED", message: "Failed to create H264 decoder", details: nil))
|
||||
}
|
||||
|
||||
case "feedNal":
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let textureId = (args["textureId"] as? NSNumber)?.int64Value,
|
||||
let nalTyped = args["nal"] as? FlutterStandardTypedData,
|
||||
let decoder = self.decoders[textureId] else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
decoder.feedNal(nalTyped.data)
|
||||
result(nil)
|
||||
|
||||
case "stopDecoder":
|
||||
if let args = call.arguments as? [String: Any],
|
||||
let textureId = (args["textureId"] as? NSNumber)?.int64Value {
|
||||
self.decoders[textureId]?.invalidate()
|
||||
self.decoders.removeValue(forKey: textureId)
|
||||
}
|
||||
result(nil)
|
||||
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
@@ -441,3 +480,206 @@ extension AppDelegate: FlutterStreamHandler {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VT decompression C callback
|
||||
|
||||
private func vtDecompressOutputCallback(
|
||||
refCon: UnsafeMutableRawPointer?,
|
||||
frameRefCon: UnsafeMutableRawPointer?,
|
||||
status: OSStatus,
|
||||
infoFlags: VTDecodeInfoFlags,
|
||||
imageBuffer: CVImageBuffer?,
|
||||
pts: CMTime,
|
||||
duration: CMTime
|
||||
) {
|
||||
guard let refCon = refCon, status == noErr, let imageBuffer = imageBuffer else { return }
|
||||
let session = Unmanaged<VideoDecoderSession>.fromOpaque(refCon).takeUnretainedValue()
|
||||
session.handleDecodedFrame(imageBuffer)
|
||||
}
|
||||
|
||||
// MARK: - VideoDecoderSession
|
||||
|
||||
class VideoDecoderSession: NSObject, FlutterTexture {
|
||||
private(set) var textureId: Int64 = -1
|
||||
|
||||
private var decompressionSession: VTDecompressionSession?
|
||||
private var formatDescription: CMVideoFormatDescription?
|
||||
private var latestPixelBuffer: CVPixelBuffer?
|
||||
private let lock = NSLock()
|
||||
private weak var textureRegistry: FlutterTextureRegistry?
|
||||
|
||||
init?(spsPps: Data, textureRegistry: FlutterTextureRegistry) {
|
||||
self.textureRegistry = textureRegistry
|
||||
super.init()
|
||||
guard setupSession(spsPps: spsPps) else { return nil }
|
||||
textureId = textureRegistry.register(self)
|
||||
}
|
||||
|
||||
// MARK: Parse Annex-B SPS+PPS and create VTDecompressionSession
|
||||
|
||||
private func setupSession(spsPps: Data) -> Bool {
|
||||
// Split on 4-byte Annex-B start codes to get raw NALU payloads
|
||||
let bytes = [UInt8](spsPps)
|
||||
var nalus: [Data] = []
|
||||
var scanStart = 0
|
||||
var i = 0
|
||||
while i + 4 <= bytes.count {
|
||||
if bytes[i] == 0 && bytes[i+1] == 0 && bytes[i+2] == 0 && bytes[i+3] == 1 {
|
||||
if i > scanStart { nalus.append(Data(bytes[scanStart..<i])) }
|
||||
scanStart = i + 4
|
||||
i += 4
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
if scanStart < bytes.count { nalus.append(Data(bytes[scanStart...])) }
|
||||
guard nalus.count >= 2 else { return false }
|
||||
|
||||
let sps = nalus[0]
|
||||
let pps = nalus[1]
|
||||
|
||||
var formatDesc: CMVideoFormatDescription?
|
||||
let fmtStatus: OSStatus = sps.withUnsafeBytes { spsBuf in
|
||||
pps.withUnsafeBytes { ppsBuf in
|
||||
var ptrs: [UnsafePointer<UInt8>] = [
|
||||
spsBuf.bindMemory(to: UInt8.self).baseAddress!,
|
||||
ppsBuf.bindMemory(to: UInt8.self).baseAddress!
|
||||
]
|
||||
var sizes: [Int] = [sps.count, pps.count]
|
||||
return CMVideoFormatDescriptionCreateFromH264ParameterSets(
|
||||
allocator: kCFAllocatorDefault,
|
||||
parameterSetCount: 2,
|
||||
parameterSetPointers: &ptrs,
|
||||
parameterSetSizes: &sizes,
|
||||
nalUnitHeaderLength: 4,
|
||||
formatDescriptionOut: &formatDesc
|
||||
)
|
||||
}
|
||||
}
|
||||
guard fmtStatus == noErr, let formatDesc = formatDesc else { return false }
|
||||
self.formatDescription = formatDesc
|
||||
|
||||
let dims = CMVideoFormatDescriptionGetDimensions(formatDesc)
|
||||
let imageAttrs: [CFString: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferWidthKey: Int(dims.width),
|
||||
kCVPixelBufferHeightKey: Int(dims.height),
|
||||
kCVPixelBufferIOSurfacePropertiesKey: [:] as [String: Any]
|
||||
]
|
||||
|
||||
var callback = VTDecompressionOutputCallbackRecord(
|
||||
decompressionOutputCallback: vtDecompressOutputCallback,
|
||||
decompressionOutputRefCon: Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
|
||||
var session: VTDecompressionSession?
|
||||
let sessionStatus = VTDecompressionSessionCreate(
|
||||
allocator: kCFAllocatorDefault,
|
||||
formatDescription: formatDesc,
|
||||
decoderSpecification: nil,
|
||||
imageBufferAttributes: imageAttrs as CFDictionary,
|
||||
outputCallback: &callback,
|
||||
decompressionSessionOut: &session
|
||||
)
|
||||
guard sessionStatus == noErr, let session = session else { return false }
|
||||
self.decompressionSession = session
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Feed an Annex-B NAL unit for decoding
|
||||
|
||||
func feedNal(_ data: Data) {
|
||||
guard let session = decompressionSession,
|
||||
let formatDesc = formatDescription,
|
||||
data.count > 4 else { return }
|
||||
|
||||
// Convert Annex-B start code to AVCC 4-byte big-endian length prefix
|
||||
let naluLen = UInt32(data.count - 4).bigEndian
|
||||
var avcc = Data(capacity: data.count)
|
||||
withUnsafeBytes(of: naluLen) { avcc.append(contentsOf: $0) }
|
||||
avcc.append(data.advanced(by: 4))
|
||||
|
||||
var blockBuffer: CMBlockBuffer?
|
||||
guard CMBlockBufferCreateWithMemoryBlock(
|
||||
allocator: kCFAllocatorDefault,
|
||||
memoryBlock: nil,
|
||||
blockLength: avcc.count,
|
||||
blockAllocator: kCFAllocatorDefault,
|
||||
customBlockSource: nil,
|
||||
offsetToData: 0,
|
||||
dataLength: avcc.count,
|
||||
flags: 0,
|
||||
blockBufferOut: &blockBuffer
|
||||
) == kCMBlockBufferNoErr, let blockBuffer = blockBuffer else { return }
|
||||
|
||||
avcc.withUnsafeBytes { ptr in
|
||||
_ = CMBlockBufferReplaceDataBytes(
|
||||
with: ptr.baseAddress!,
|
||||
blockBuffer: blockBuffer,
|
||||
offsetIntoDestination: 0,
|
||||
dataLength: avcc.count
|
||||
)
|
||||
}
|
||||
|
||||
var timingInfo = CMSampleTimingInfo(
|
||||
duration: CMTime(value: 1, timescale: 60),
|
||||
presentationTimeStamp: CMTime(seconds: CACurrentMediaTime(), preferredTimescale: 600),
|
||||
decodeTimeStamp: .invalid
|
||||
)
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
guard CMSampleBufferCreate(
|
||||
allocator: kCFAllocatorDefault,
|
||||
dataBuffer: blockBuffer,
|
||||
dataReady: true,
|
||||
makeDataReadyCallback: nil,
|
||||
refcon: nil,
|
||||
formatDescription: formatDesc,
|
||||
sampleCount: 1,
|
||||
sampleTimingEntryCount: 1,
|
||||
sampleTimingArray: &timingInfo,
|
||||
sampleSizeEntryCount: 0,
|
||||
sampleSizeArray: nil,
|
||||
sampleBufferOut: &sampleBuffer
|
||||
) == noErr, let sampleBuffer = sampleBuffer else { return }
|
||||
|
||||
VTDecompressionSessionDecodeFrame(
|
||||
session,
|
||||
sampleBuffer: sampleBuffer,
|
||||
flags: [],
|
||||
frameRefcon: nil,
|
||||
infoFlagsOut: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Called from the VT C callback
|
||||
|
||||
func handleDecodedFrame(_ imageBuffer: CVImageBuffer) {
|
||||
lock.lock()
|
||||
latestPixelBuffer = imageBuffer as CVPixelBuffer
|
||||
lock.unlock()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.textureRegistry?.textureFrameAvailable(self.textureId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: FlutterTexture
|
||||
|
||||
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let pb = latestPixelBuffer else { return nil }
|
||||
return Unmanaged.passRetained(pb)
|
||||
}
|
||||
|
||||
// MARK: Teardown
|
||||
|
||||
func invalidate() {
|
||||
if let session = decompressionSession {
|
||||
VTDecompressionSessionInvalidate(session)
|
||||
}
|
||||
decompressionSession = nil
|
||||
latestPixelBuffer = nil
|
||||
textureRegistry?.unregisterTexture(textureId)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user