implement video encoding and decoding functionality with screen capture support

This commit is contained in:
ImBenji
2026-05-05 04:39:18 +01:00
parent bfdaf4a801
commit 37fef7de25
10 changed files with 1163 additions and 48 deletions
+242
View File
@@ -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)
}
}