LNVideoCompressor.swift 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. //
  2. // LNVideoCompressor.swift
  3. // Gami
  4. //
  5. // Created by OneeChan on 2026/2/28.
  6. //
  7. import Foundation
  8. import AVFoundation
  9. class LNVideoCompressor {
  10. static func compressVideo(sourceURL: URL, completion: @escaping (URL?) -> Void) {
  11. DispatchQueue.global().async {
  12. // 检查源文件是否存在
  13. guard FileManager.default.fileExists(atPath: sourceURL.path) else {
  14. showToast(.init(key: "B00014"))
  15. completion(nil)
  16. return
  17. }
  18. // 获取视频资产
  19. let asset = AVAsset(url: sourceURL)
  20. // 计算目标码率(关键:码率决定文件大小)
  21. let videoDuration = CMTimeGetSeconds(asset.duration)
  22. guard videoDuration > 0, videoDuration <= 1 * 60 else {
  23. showToast(.init(key: "A00299"))
  24. completion(nil)
  25. return
  26. }
  27. guard let videoTrack = asset.tracks(withMediaType: .video).first else {
  28. showToast(.init(key: "A00298", 10001))
  29. completion(nil)
  30. return
  31. }
  32. do {
  33. // 创建写入器:指定输出格式为 MP4,并传入流媒体配置
  34. let outputUrl = getTempVideoURL()
  35. // 确保临时文件不存在(避免写入冲突)
  36. if FileManager.default.fileExists(atPath: outputUrl.path) {
  37. try FileManager.default.removeItem(at: outputUrl)
  38. }
  39. let writer = try AVAssetWriter(outputURL: outputUrl, fileType: .mp4)
  40. // 关键配置:启用 interleaved 写入(确保 moov atom 前置)
  41. writer.shouldOptimizeForNetworkUse = true // 核心!开启网络优化(流式播放)
  42. // 配置视频编码参数(流媒体核心参数)
  43. let videoSettings = createCompressionSettings(asset: asset)
  44. // 创建视频输入
  45. let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
  46. videoInput.expectsMediaDataInRealTime = false
  47. videoInput.transform = videoTrack.preferredTransform // 保持原视频方向
  48. guard writer.canAdd(videoInput) else {
  49. showToast(.init(key: "A00298", 10002))
  50. completion(nil)
  51. return
  52. }
  53. writer.add(videoInput)
  54. // 创建音频输入
  55. var audioInput: AVAssetWriterInput?
  56. if asset.tracks(withMediaType: .audio).first != nil {
  57. let audioSettings = [
  58. AVFormatIDKey: kAudioFormatMPEG4AAC,
  59. AVSampleRateKey: 44100,
  60. AVNumberOfChannelsKey: 2,
  61. AVEncoderBitRateKey: 128000 // 128Kbps 音频比特率
  62. ]
  63. let input = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
  64. input.expectsMediaDataInRealTime = false
  65. if writer.canAdd(input) {
  66. writer.add(input)
  67. audioInput = input
  68. }
  69. }
  70. // 创建 AVAssetReader
  71. let reader = try AVAssetReader(asset: asset)
  72. // 创建视频读取器
  73. let readerOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: [
  74. kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
  75. ])
  76. guard reader.canAdd(readerOutput) else {
  77. showToast(.init(key: "A00298", 10003))
  78. completion(nil)
  79. return
  80. }
  81. reader.add(readerOutput)
  82. // 处理音频
  83. var audioOutput: AVAssetReaderTrackOutput?
  84. if let audioTrack = asset.tracks(withMediaType: .audio).first {
  85. // 音频读取器输出
  86. let output = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: [
  87. AVFormatIDKey: kAudioFormatLinearPCM
  88. ])
  89. if reader.canAdd(output) {
  90. reader.add(output)
  91. audioOutput = output
  92. }
  93. }
  94. // 开始写入
  95. guard writer.startWriting() else {
  96. showToast(.init(key: "A00298", 10004))
  97. completion(nil)
  98. return
  99. }
  100. writer.startSession(atSourceTime: .zero)
  101. guard reader.startReading() else {
  102. showToast(.init(key: "A00298", 10005))
  103. completion(nil)
  104. return
  105. }
  106. let dispatchGroup = DispatchGroup()
  107. // 逐帧处理并写入
  108. dispatchGroup.enter()
  109. videoInput.requestMediaDataWhenReady(on: DispatchQueue(label: "com.jiehe.gami.video.compress")) {
  110. while videoInput.isReadyForMoreMediaData {
  111. if let sampleBuffer = readerOutput.copyNextSampleBuffer() {
  112. videoInput.append(sampleBuffer)
  113. } else {
  114. videoInput.markAsFinished()
  115. dispatchGroup.leave()
  116. break
  117. }
  118. }
  119. }
  120. // 处理音频
  121. if let audioInput, let audioOutput {
  122. dispatchGroup.enter()
  123. audioInput.requestMediaDataWhenReady(on: DispatchQueue(label: "com.jiehe.gami.audio.compress")) {
  124. while audioInput.isReadyForMoreMediaData {
  125. if let sampleBuffer = audioOutput.copyNextSampleBuffer() {
  126. audioInput.append(sampleBuffer)
  127. } else {
  128. audioInput.markAsFinished()
  129. dispatchGroup.leave()
  130. break
  131. }
  132. }
  133. }
  134. }
  135. // 等待完成
  136. dispatchGroup.notify(queue: .global()) {
  137. writer.finishWriting {
  138. switch writer.status {
  139. case .completed:
  140. completion(outputUrl)
  141. case .failed:
  142. let err = reader.error ?? writer.error
  143. showToast("\(err?.localizedDescription ?? "")")
  144. completion(nil)
  145. default:
  146. break
  147. }
  148. }
  149. }
  150. } catch {
  151. showToast(error.localizedDescription)
  152. completion(nil)
  153. }
  154. }
  155. }
  156. }
  157. extension LNVideoCompressor {
  158. /// 创建压缩设置(视频码率、分辨率)
  159. private static func createCompressionSettings(asset: AVAsset) -> [String: Any] {
  160. var settings = [String: Any]()
  161. // 获取视频轨道
  162. guard let videoTrack = asset.tracks(withMediaType: .video).first else {
  163. return settings
  164. }
  165. // 视频编码类型
  166. settings[AVVideoCodecKey] = AVVideoCodecType.h264 // H.264编码(兼容性好)
  167. // 分辨率设置(保持原比例,避免拉伸)
  168. let naturalSize = videoTrack.naturalSize
  169. let transform = videoTrack.preferredTransform
  170. var videoSize = naturalSize
  171. // // 处理旋转
  172. // if transform.a == 0 && transform.b == 1.0 && transform.c == -1.0 && transform.d == 0 ||
  173. // transform.a == 0 && transform.b == -1.0 && transform.c == 1.0 && transform.d == 0 {
  174. // videoSize = CGSize(width: naturalSize.height, height: naturalSize.width)
  175. // }
  176. // 限制最大分辨率(限制为720P)
  177. let maxDimension: CGFloat = 720
  178. let scaleFactor = min(maxDimension / videoSize.width, maxDimension / videoSize.height, 1.0)
  179. let finalWidth = floor(videoSize.width * scaleFactor)
  180. let finalHeight = floor(videoSize.height * scaleFactor)
  181. // 确保宽高为偶数(H.264要求)
  182. let outputWidth = finalWidth.truncatingRemainder(dividingBy: 2) == 0 ? finalWidth : finalWidth - 1
  183. let outputHeight = finalHeight.truncatingRemainder(dividingBy: 2) == 0 ? finalHeight : finalHeight - 1
  184. settings[AVVideoWidthKey] = outputWidth
  185. settings[AVVideoHeightKey] = outputHeight
  186. // 流媒体关键配置
  187. settings[AVVideoCompressionPropertiesKey] = [
  188. // 2. 比特率模式:恒定比特率(CBR),适合流媒体
  189. AVVideoAverageBitRateKey: 1000000, // 1Mbps
  190. // 3. H.264 配置:兼容流媒体
  191. AVVideoProfileLevelKey: AVVideoProfileLevelH264Baseline31, // Baseline 级别兼容性更好
  192. AVVideoAllowFrameReorderingKey: false // 禁止帧重排序,便于流媒体解析
  193. ]
  194. return settings
  195. }
  196. /// 获取临时视频文件URL
  197. private static func getTempVideoURL() -> URL {
  198. let tempDir = NSTemporaryDirectory()
  199. let fileName = UUID().uuidString + ".mp4"
  200. return URL(fileURLWithPath: tempDir).appendingPathComponent(fileName)
  201. }
  202. }