mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-04-18 14:42:54 +00:00
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
253 lines
12 KiB
Swift
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))
|
|
}
|
|
}
|
|
}
|