Files
Qlyra/ios/Runner/QlyraVideoNote.swift
T
sevenhill f74057ce9b
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Rebrand application as Qlyra
2026-08-30 13:16:17 +03:00

403 lines
13 KiB
Swift

import AVFoundation
import CoreImage
import Flutter
import UIKit
final class QlyraVideoNoteTexture: NSObject, FlutterTexture {
private let lock = NSLock()
private var latest: CVPixelBuffer?
func push(_ buffer: CVPixelBuffer) {
lock.lock()
latest = buffer
lock.unlock()
}
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
lock.lock()
defer { lock.unlock() }
guard let buffer = latest else { return nil }
return Unmanaged.passRetained(buffer)
}
}
final class QlyraVideoNote: NSObject {
private let registry: FlutterTextureRegistry
private let queue = DispatchQueue(label: "ru.qlyra.app.videonote", qos: .userInitiated)
private let ciContext = CIContext(options: [.useSoftwareRenderer: false])
private let session = AVCaptureSession()
private let videoOutput = AVCaptureVideoDataOutput()
private let audioOutput = AVCaptureAudioDataOutput()
private let texture = QlyraVideoNoteTexture()
private var textureId: Int64 = 0
private var deviceInput: AVCaptureDeviceInput?
private var audioInput: AVCaptureDeviceInput?
private var position: AVCaptureDevice.Position = .front
private var edge: Int = 480
private var fps: Int = 30
private var pixelBufferPool: CVPixelBufferPool?
private var writer: AVAssetWriter?
private var writerVideo: AVAssetWriterInput?
private var writerAudio: AVAssetWriterInput?
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
private var outputURL: URL?
private var recording = false
private var sessionStarted = false
init(registry: FlutterTextureRegistry) {
self.registry = registry
super.init()
}
static func requestPermission(_ result: @escaping FlutterResult) {
AVCaptureDevice.requestAccess(for: .video) { video in
AVCaptureDevice.requestAccess(for: .audio) { audio in
DispatchQueue.main.async {
result(["camera": NSNumber(value: video), "microphone": NSNumber(value: audio)])
}
}
}
}
func initialize(front: Bool, edge: Int, fps: Int, result: @escaping FlutterResult) {
self.position = front ? .front : .back
self.edge = max(16, edge)
self.fps = max(1, fps)
queue.async {
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized else {
Self.fail(result, "NO_CAMERA_PERMISSION", "camera permission required")
return
}
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
Self.fail(result, "NO_MIC_PERMISSION", "microphone permission required")
return
}
do {
try self.configureSession()
} catch {
Self.fail(result, "NO_CAMERA", error.localizedDescription)
return
}
self.session.startRunning()
DispatchQueue.main.async {
if self.textureId == 0 {
self.textureId = self.registry.register(self.texture)
}
result(["textureId": NSNumber(value: self.textureId), "hasFlash": self.hasTorch()])
}
}
}
func switchCamera(result: @escaping FlutterResult) {
queue.async {
self.position = self.position == .front ? .back : .front
do {
try self.configureSession()
} catch {
Self.fail(result, "NO_CAMERA", error.localizedDescription)
return
}
DispatchQueue.main.async { result(nil) }
}
}
func setTorch(on: Bool, result: @escaping FlutterResult) {
queue.async {
guard let device = self.deviceInput?.device, device.hasTorch else {
DispatchQueue.main.async { result(NSNumber(value: false)) }
return
}
var applied = false
if (try? device.lockForConfiguration()) != nil {
device.torchMode = on ? .on : .off
device.unlockForConfiguration()
applied = on
}
DispatchQueue.main.async { result(NSNumber(value: applied)) }
}
}
func start(result: @escaping FlutterResult) {
queue.async {
guard !self.recording else {
DispatchQueue.main.async { result(nil) }
return
}
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
Self.fail(result, "NO_MIC_PERMISSION", "microphone permission required")
return
}
do {
try self.prepareWriter()
} catch {
Self.fail(result, "START_FAILED", error.localizedDescription)
return
}
self.sessionStarted = false
self.recording = true
DispatchQueue.main.async { result(nil) }
}
}
func stop(result: @escaping FlutterResult) {
queue.async {
guard self.recording, let writer = self.writer else {
Self.fail(result, "NOT_RECORDING", "no active recording")
return
}
self.recording = false
self.writerVideo?.markAsFinished()
self.writerAudio?.markAsFinished()
let url = self.outputURL
writer.finishWriting {
let ok = writer.status == .completed
self.writer = nil
self.writerVideo = nil
self.writerAudio = nil
self.adaptor = nil
self.outputURL = nil
let path: String? = ok ? url?.path : nil
DispatchQueue.main.async {
result(path)
}
}
}
}
func dispose() {
queue.sync {
if self.recording {
self.recording = false
self.writerVideo?.markAsFinished()
self.writerAudio?.markAsFinished()
self.writer?.cancelWriting()
self.writer = nil
}
if self.session.isRunning { self.session.stopRunning() }
for input in self.session.inputs { self.session.removeInput(input) }
for output in self.session.outputs { self.session.removeOutput(output) }
self.deviceInput = nil
self.audioInput = nil
self.pixelBufferPool = nil
}
if textureId != 0 {
registry.unregisterTexture(textureId)
textureId = 0
}
}
private func hasTorch() -> Bool {
deviceInput?.device.hasTorch ?? false
}
private func configureSession() throws {
session.beginConfiguration()
defer { session.commitConfiguration() }
if let existing = deviceInput {
session.removeInput(existing)
deviceInput = nil
}
guard let device = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: position)
?? AVCaptureDevice.default(for: .video) else {
throw NSError(domain: "QlyraVideoNote", code: 1,
userInfo: [NSLocalizedDescriptionKey: "no camera found"])
}
let input = try AVCaptureDeviceInput(device: device)
guard session.canAddInput(input) else {
throw NSError(domain: "QlyraVideoNote", code: 2,
userInfo: [NSLocalizedDescriptionKey: "camera input rejected"])
}
session.addInput(input)
deviceInput = input
if session.canSetSessionPreset(.hd1280x720) {
session.sessionPreset = .hd1280x720
}
if audioInput == nil,
let microphone = AVCaptureDevice.default(for: .audio),
let input = try? AVCaptureDeviceInput(device: microphone),
session.canAddInput(input) {
session.addInput(input)
audioInput = input
}
if !session.outputs.contains(videoOutput) {
videoOutput.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
]
videoOutput.alwaysDiscardsLateVideoFrames = true
videoOutput.setSampleBufferDelegate(self, queue: queue)
if session.canAddOutput(videoOutput) { session.addOutput(videoOutput) }
}
if !session.outputs.contains(audioOutput) {
audioOutput.setSampleBufferDelegate(self, queue: queue)
if session.canAddOutput(audioOutput) { session.addOutput(audioOutput) }
}
if let connection = videoOutput.connection(with: .video) {
if connection.isVideoOrientationSupported {
connection.videoOrientation = .portrait
}
if connection.isVideoMirroringSupported {
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = position == .front
}
}
if (try? device.lockForConfiguration()) != nil {
let duration = CMTimeMake(value: 1, timescale: Int32(fps))
if device.activeFormat.videoSupportedFrameRateRanges.contains(where: {
$0.minFrameRate <= Double(fps) && Double(fps) <= $0.maxFrameRate
}) {
device.activeVideoMinFrameDuration = duration
device.activeVideoMaxFrameDuration = duration
}
device.unlockForConfiguration()
}
pixelBufferPool = Self.makePool(edge: edge)
}
private func prepareWriter() throws {
let directory = FileManager.default.temporaryDirectory
let url = directory.appendingPathComponent(
"qlyra_note_\(Int(Date().timeIntervalSince1970 * 1000)).mp4")
try? FileManager.default.removeItem(at: url)
let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: edge,
AVVideoHeightKey: edge,
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: 1_024_000,
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
],
]
let video = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
video.expectsMediaDataInRealTime = true
guard writer.canAdd(video) else {
throw NSError(domain: "QlyraVideoNote", code: 3,
userInfo: [NSLocalizedDescriptionKey: "video input rejected"])
}
writer.add(video)
let audioSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVNumberOfChannelsKey: 1,
AVSampleRateKey: 44100,
AVEncoderBitRateKey: 64000,
]
let audio = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
audio.expectsMediaDataInRealTime = true
if writer.canAdd(audio) { writer.add(audio) }
adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: video,
sourcePixelBufferAttributes: [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: edge,
kCVPixelBufferHeightKey as String: edge,
])
guard writer.startWriting() else {
throw NSError(domain: "QlyraVideoNote", code: 4,
userInfo: [NSLocalizedDescriptionKey: "writer refused to start"])
}
self.writer = writer
self.writerVideo = video
self.writerAudio = writer.inputs.contains(audio) ? audio : nil
self.outputURL = url
}
private static func makePool(edge: Int) -> CVPixelBufferPool? {
let attributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: edge,
kCVPixelBufferHeightKey as String: edge,
kCVPixelBufferIOSurfacePropertiesKey as String: [String: Any](),
]
var pool: CVPixelBufferPool?
CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attributes as CFDictionary, &pool)
return pool
}
private func squareBuffer(from source: CVPixelBuffer) -> CVPixelBuffer? {
guard let pool = pixelBufferPool else { return nil }
var target: CVPixelBuffer?
guard CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &target)
== kCVReturnSuccess, let output = target else { return nil }
let image = CIImage(cvPixelBuffer: source)
let extent = image.extent
let side = min(extent.width, extent.height)
let cropped = image.cropped(to: CGRect(
x: extent.midX - side / 2,
y: extent.midY - side / 2,
width: side,
height: side))
let scale = CGFloat(edge) / side
let scaled = cropped
.transformed(by: CGAffineTransform(translationX: -cropped.extent.origin.x,
y: -cropped.extent.origin.y))
.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
ciContext.render(scaled, to: output)
return output
}
private static func fail(_ result: @escaping FlutterResult, _ code: String, _ message: String) {
DispatchQueue.main.async {
result(FlutterError(code: code, message: message, details: nil))
}
}
}
extension QlyraVideoNote: AVCaptureVideoDataOutputSampleBufferDelegate,
AVCaptureAudioDataOutputSampleBufferDelegate {
func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
if output === audioOutput {
appendAudio(sampleBuffer)
return
}
guard let source = CMSampleBufferGetImageBuffer(sampleBuffer),
let square = squareBuffer(from: source) else { return }
texture.push(square)
let id = textureId
if id != 0 {
DispatchQueue.main.async { self.registry.textureFrameAvailable(id) }
}
guard recording, let writer = writer, let input = writerVideo,
let adaptor = adaptor else { return }
let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
if !sessionStarted {
writer.startSession(atSourceTime: timestamp)
sessionStarted = true
}
guard input.isReadyForMoreMediaData else { return }
adaptor.append(square, withPresentationTime: timestamp)
}
private func appendAudio(_ sampleBuffer: CMSampleBuffer) {
guard recording, sessionStarted, let input = writerAudio,
input.isReadyForMoreMediaData else { return }
input.append(sampleBuffer)
}
}