twonly-app/ios/Runner/VideoCompressionChannel.swift
otsmr c85914672e
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Improve: Video compression with progress updates
2026-03-13 01:35:04 +01:00

253 lines
12 KiB
Swift

import Foundation
import Flutter
import AVFoundation
class VideoCompressionChannel {
private let channelName = "eu.twonly/videoCompression"
// Hold a strong reference so the instance isn't immediately deallocated
private static var activeInstance: VideoCompressionChannel?
static func register(with messenger: FlutterBinaryMessenger) {
let instance = VideoCompressionChannel()
activeInstance = instance
let channel = FlutterMethodChannel(name: instance.channelName, binaryMessenger: messenger)
print("[VideoCompressionChannel] Registered channel: \(instance.channelName)")
channel.setMethodCallHandler { [weak instance] (call: FlutterMethodCall, result: @escaping FlutterResult) in
print("[VideoCompressionChannel] Received method call: \(call.method)")
instance?.handle(call, result: result, channel: channel)
}
}
init() {}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult, channel: FlutterMethodChannel) {
if call.method == "compressVideo" {
guard let args = call.arguments as? [String: Any],
let inputPath = args["input"] as? String,
let outputPath = args["output"] as? String else {
print("[VideoCompressionChannel] Error: Missing input or output path in arguments")
result(FlutterError(code: "INVALID_ARGS", message: "Input or output path missing", details: nil))
return
}
print("[VideoCompressionChannel] Starting compressVideo from \(inputPath) to \(outputPath)")
compress(inputPath: inputPath, outputPath: outputPath, channel: channel, result: result)
} else {
print("[VideoCompressionChannel] Method not implemented: \(call.method)")
result(FlutterMethodNotImplemented)
}
}
func compress(inputPath: String, outputPath: String, channel: FlutterMethodChannel, result: @escaping FlutterResult) {
let inputURL = URL(fileURLWithPath: inputPath)
let outputURL = URL(fileURLWithPath: outputPath)
if FileManager.default.fileExists(atPath: outputURL.path) {
print("[VideoCompressionChannel] Removing existing file at output path")
try? FileManager.default.removeItem(at: outputURL)
}
let asset = AVAsset(url: inputURL)
guard let videoTrack = asset.tracks(withMediaType: .video).first else {
print("[VideoCompressionChannel] Error: No video track found in asset")
result(FlutterError(code: "NO_VIDEO_TRACK", message: "Video track not found", details: nil))
return
}
let naturalSize = videoTrack.naturalSize
let transform = videoTrack.preferredTransform
let isPortrait = transform.a == 0 && abs(transform.b) == 1.0 && abs(transform.c) == 1.0 && transform.d == 0
let originalWidth = isPortrait ? naturalSize.height : naturalSize.width
let originalHeight = isPortrait ? naturalSize.width : naturalSize.height
let maxDimension: CGFloat = 1920.0
let minDimension: CGFloat = 1080.0
var targetWidth = originalWidth
var targetHeight = originalHeight
if targetWidth > maxDimension || targetHeight > maxDimension {
let widthRatio = maxDimension / targetWidth
let heightRatio = minDimension / targetHeight
let scaleFactor = min(widthRatio, heightRatio)
targetWidth *= scaleFactor
targetHeight *= scaleFactor
}
let targetBitrate = 3_000_000
do {
let reader = try AVAssetReader(asset: asset)
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
writer.shouldOptimizeForNetworkUse = true
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.hevc,
AVVideoWidthKey: Int(targetWidth),
AVVideoHeightKey: Int(targetHeight),
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: targetBitrate
]
]
let readerOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
])
let writerInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
writerInput.expectsMediaDataInRealTime = false
writerInput.transform = videoTrack.preferredTransform
guard writer.canAdd(writerInput) else {
result(FlutterError(code: "WRITER_ERROR", message: "Cannot add video writer input", details: nil))
return
}
guard reader.canAdd(readerOutput) else {
result(FlutterError(code: "READER_ERROR", message: "Cannot add video reader output", details: nil))
return
}
reader.add(readerOutput)
writer.add(writerInput)
// Audio processing (re-encode to AAC)
var audioReaderOutput: AVAssetReaderTrackOutput?
var audioWriterInput: AVAssetWriterInput?
if let audioTrack = asset.tracks(withMediaType: .audio).first {
let audioReaderSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM
]
let aReaderOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: audioReaderSettings)
let audioWriterSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVNumberOfChannelsKey: 2,
AVSampleRateKey: 44100,
AVEncoderBitRateKey: 128000
]
let aWriterInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioWriterSettings)
aWriterInput.expectsMediaDataInRealTime = false
if reader.canAdd(aReaderOutput) && writer.canAdd(aWriterInput) {
reader.add(aReaderOutput)
writer.add(aWriterInput)
audioReaderOutput = aReaderOutput
audioWriterInput = aWriterInput
} else {
print("[VideoCompressionChannel] Warning: Cannot add audio tracks, proceeding without audio")
}
}
guard reader.startReading() else {
result(FlutterError(code: "READER_ERROR", message: "Cannot start reading: \(reader.error?.localizedDescription ?? "unknown error")", details: nil))
return
}
guard writer.startWriting() else {
result(FlutterError(code: "WRITER_ERROR", message: "Cannot start writing: \(writer.error?.localizedDescription ?? "unknown error")", details: nil))
return
}
writer.startSession(atSourceTime: .zero)
let duration = CMTimeGetSeconds(asset.duration)
let videoQueue = DispatchQueue(label: "videoQueue")
let audioQueue = DispatchQueue(label: "audioQueue")
let group = DispatchGroup()
// State tracking flag to avoid sending completed messages prematurely
var isVideoCompleted = false
var isAudioCompleted = audioWriterInput == nil
group.enter()
writerInput.requestMediaDataWhenReady(on: videoQueue) {
while writerInput.isReadyForMoreMediaData {
if reader.status != .reading {
if !isVideoCompleted {
isVideoCompleted = true
writerInput.markAsFinished()
group.leave()
}
return
}
if let sampleBuffer = readerOutput.copyNextSampleBuffer() {
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
let timeInSeconds = CMTimeGetSeconds(presentationTime)
if duration > 0 {
let progress = Int((timeInSeconds / duration) * 100)
DispatchQueue.main.async {
channel.invokeMethod("onProgress", arguments: ["progress": progress])
}
}
writerInput.append(sampleBuffer)
} else {
if !isVideoCompleted {
isVideoCompleted = true
writerInput.markAsFinished()
group.leave()
}
break
}
}
}
if let audioWriterInput = audioWriterInput, let audioReaderOutput = audioReaderOutput {
group.enter()
audioWriterInput.requestMediaDataWhenReady(on: audioQueue) {
while audioWriterInput.isReadyForMoreMediaData {
if reader.status != .reading {
if !isAudioCompleted {
isAudioCompleted = true
audioWriterInput.markAsFinished()
group.leave()
}
return
}
if let sampleBuffer = audioReaderOutput.copyNextSampleBuffer() {
audioWriterInput.append(sampleBuffer)
} else {
if !isAudioCompleted {
isAudioCompleted = true
audioWriterInput.markAsFinished()
group.leave()
}
break
}
}
}
}
group.notify(queue: .main) {
if reader.status == .completed {
writer.finishWriting {
if writer.status == .completed {
print("[VideoCompressionChannel] Compression completed successfully!")
result(outputPath)
} else {
print("[VideoCompressionChannel] Writer Error: \(writer.error?.localizedDescription ?? "Unknown error")")
result(FlutterError(code: "WRITER_ERROR", message: writer.error?.localizedDescription, details: nil))
}
}
} else {
writer.cancelWriting()
print("[VideoCompressionChannel] Reader Error: \(reader.error?.localizedDescription ?? "Unknown error")")
result(FlutterError(code: "READER_ERROR", message: reader.error?.localizedDescription, details: nil))
}
}
} catch {
print("[VideoCompressionChannel] Exception: \(error.localizedDescription)")
result(FlutterError(code: "COMPRESS_ERROR", message: error.localizedDescription, details: nil))
}
}
}