expo-pro-video-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.prettierrc +8 -0
  2. package/LICENSE +67 -0
  3. package/NOTICE.md +250 -0
  4. package/README.md +70 -0
  5. package/android/build.gradle +35 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/expo/modules/provideoeditor/ExpoProVideoEditorModule.kt +163 -0
  8. package/android/src/main/java/expo/modules/provideoeditor/src/core/constants/LoggingConstants.kt +4 -0
  9. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/EffectsProcessor.kt +66 -0
  10. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/RenderVideo.kt +1089 -0
  11. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyAnimation.kt +290 -0
  12. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyBitrate.kt +110 -0
  13. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyBlur.kt +27 -0
  14. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyChromaKey.kt +58 -0
  15. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyClipTransition.kt +88 -0
  16. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyColorMatrix.kt +144 -0
  17. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyComposition.kt +86 -0
  18. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyCrop.kt +104 -0
  19. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyFlip.kt +27 -0
  20. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyFrameRate.kt +28 -0
  21. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyImageLayer.kt +397 -0
  22. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyOpacity.kt +24 -0
  23. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyPlaybackSpeed.kt +35 -0
  24. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyRotation.kt +26 -0
  25. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyScale.kt +29 -0
  26. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/AudioPreRenderer.kt +492 -0
  27. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/AudioSequenceBuilder.kt +144 -0
  28. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/BitrateCapPolicy.kt +45 -0
  29. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ChromaKeyEffect.kt +321 -0
  30. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ChromaKeyMath.kt +90 -0
  31. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ClipTransitionGeometry.kt +160 -0
  32. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ClipTransitionRenderer.kt +1002 -0
  33. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/CompositionBuilder.kt +166 -0
  34. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ConfigurableInAppMp4Muxer.kt +137 -0
  35. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/EncoderFailureClassifier.kt +71 -0
  36. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/GifDecoder.kt +76 -0
  37. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/LayeredCompositionBuilder.kt +556 -0
  38. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/MediaInfoExtractor.kt +364 -0
  39. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ResilientVideoEncoderFactory.kt +307 -0
  40. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoCompositionTransformation.kt +192 -0
  41. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoEncoderConfig.kt +179 -0
  42. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoGlobalTrimCalculator.kt +147 -0
  43. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoReverser.kt +1044 -0
  44. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoSequenceBuilder.kt +905 -0
  45. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoTimelineDurationCalculator.kt +19 -0
  46. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoTranscoder.kt +242 -0
  47. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VolumeAudioProcessor.kt +82 -0
  48. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VolumeControlAudioMixer.kt +182 -0
  49. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/CodecResourceExhaustedException.kt +24 -0
  50. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/RenderConfig.kt +572 -0
  51. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/RenderJobHandle.kt +45 -0
  52. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/VideoEncoderConfigurationException.kt +20 -0
  53. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/utils/RotatedVideoDimensions.kt +68 -0
  54. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/utils/VideoMimeUtils.kt +36 -0
  55. package/android/src/main/java/expo/modules/provideoeditor/src/shared/concurrency/ExportGate.kt +102 -0
  56. package/android/src/main/java/expo/modules/provideoeditor/src/shared/logging/PluginLog.kt +112 -0
  57. package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/EncodedImage.kt +101 -0
  58. package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/ImageOrientation.kt +265 -0
  59. package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/PcmRangeDecoder.kt +239 -0
  60. package/build/ExpoProVideoEditor.types.d.ts +125 -0
  61. package/build/ExpoProVideoEditor.types.d.ts.map +1 -0
  62. package/build/ExpoProVideoEditor.types.js +8 -0
  63. package/build/ExpoProVideoEditor.types.js.map +1 -0
  64. package/build/ExpoProVideoEditorModule.d.ts +20 -0
  65. package/build/ExpoProVideoEditorModule.d.ts.map +1 -0
  66. package/build/ExpoProVideoEditorModule.js +3 -0
  67. package/build/ExpoProVideoEditorModule.js.map +1 -0
  68. package/build/ExpoProVideoEditorModule.web.d.ts +9 -0
  69. package/build/ExpoProVideoEditorModule.web.d.ts.map +1 -0
  70. package/build/ExpoProVideoEditorModule.web.js +16 -0
  71. package/build/ExpoProVideoEditorModule.web.js.map +1 -0
  72. package/build/index.d.ts +3 -0
  73. package/build/index.d.ts.map +1 -0
  74. package/build/index.js +5 -0
  75. package/build/index.js.map +1 -0
  76. package/bun.lock +2132 -0
  77. package/eslint.config.cjs +5 -0
  78. package/expo-module.config.json +9 -0
  79. package/ios/ExpoProVideoEditor.podspec +24 -0
  80. package/ios/ExpoProVideoEditorModule.swift +131 -0
  81. package/ios/src/features/render/RenderVideo.swift +1087 -0
  82. package/ios/src/features/render/helpers/ApplyAnimation.swift +198 -0
  83. package/ios/src/features/render/helpers/ApplyBitrate.swift +112 -0
  84. package/ios/src/features/render/helpers/ApplyBlur.swift +23 -0
  85. package/ios/src/features/render/helpers/ApplyChromaKey.swift +175 -0
  86. package/ios/src/features/render/helpers/ApplyColorMatrix.swift +100 -0
  87. package/ios/src/features/render/helpers/ApplyComposition.swift +45 -0
  88. package/ios/src/features/render/helpers/ApplyCrop.swift +53 -0
  89. package/ios/src/features/render/helpers/ApplyFlip.swift +24 -0
  90. package/ios/src/features/render/helpers/ApplyImageLayer.swift +65 -0
  91. package/ios/src/features/render/helpers/ApplyPlaybackSpeed.swift +57 -0
  92. package/ios/src/features/render/helpers/ApplyRotation.swift +32 -0
  93. package/ios/src/features/render/helpers/ApplyScale.swift +30 -0
  94. package/ios/src/features/render/helpers/AudioPreRenderer.swift +352 -0
  95. package/ios/src/features/render/helpers/AudioReverser.swift +308 -0
  96. package/ios/src/features/render/helpers/AudioSequenceBuilder.swift +162 -0
  97. package/ios/src/features/render/helpers/BitrateCapPolicy.swift +77 -0
  98. package/ios/src/features/render/helpers/BitrateCappedExporter.swift +501 -0
  99. package/ios/src/features/render/helpers/ClipTransitionGeometry.swift +111 -0
  100. package/ios/src/features/render/helpers/ClipTransitionRenderer.swift +397 -0
  101. package/ios/src/features/render/helpers/CompositionBuilder.swift +464 -0
  102. package/ios/src/features/render/helpers/DecodeOrientedImage.swift +60 -0
  103. package/ios/src/features/render/helpers/LayeredCompositionBuilder.swift +371 -0
  104. package/ios/src/features/render/helpers/MediaInfoExtractor.swift +381 -0
  105. package/ios/src/features/render/helpers/TrackEndTrimmer.swift +112 -0
  106. package/ios/src/features/render/helpers/VideoSequenceBuilder.swift +555 -0
  107. package/ios/src/features/render/helpers/VideoTranscoder.swift +283 -0
  108. package/ios/src/features/render/models/RenderConfig.swift +586 -0
  109. package/ios/src/features/render/models/VideoClip.swift +81 -0
  110. package/ios/src/features/render/models/VideoCompositorConfig.swift +78 -0
  111. package/ios/src/features/render/utils/VideoCompositor.swift +1014 -0
  112. package/ios/src/features/render/utils/VideoMimeUtils.swift +24 -0
  113. package/ios/src/shared/EncodedImage.swift +75 -0
  114. package/ios/src/shared/ExportGate.swift +79 -0
  115. package/ios/src/shared/ExportSessionDriver.swift +185 -0
  116. package/ios/src/shared/ExportSessionGuard.swift +85 -0
  117. package/ios/src/shared/ExportWatchdog.swift +198 -0
  118. package/ios/src/shared/LoggingConstants.swift +7 -0
  119. package/ios/src/shared/PluginLog.swift +107 -0
  120. package/package.json +59 -0
  121. package/src/ExpoProVideoEditor.types.ts +148 -0
  122. package/src/ExpoProVideoEditorModule.ts +22 -0
  123. package/src/ExpoProVideoEditorModule.web.ts +22 -0
  124. package/src/index.ts +4 -0
  125. package/tsconfig.json +28 -0
@@ -0,0 +1,283 @@
1
+ import AVFoundation
2
+ import CoreImage
3
+ import Foundation
4
+
5
+ /// Utility for pre-transcoding HEVC 10-bit HDR videos to H.264 8-bit SDR.
6
+ ///
7
+ /// This is necessary because GPU-based effect pipelines have compatibility
8
+ /// issues with HEVC Main 10 Profile videos. By transcoding to H.264 first,
9
+ /// we can then safely apply effects like ColorMatrix, Blur, or Overlay.
10
+ internal class VideoTranscoder {
11
+
12
+ // MARK: - Result Types
13
+
14
+ /// Result of a transcoding operation.
15
+ enum TranscodeResult {
16
+ /// Transcoding succeeded, contains path to transcoded file
17
+ case success(outputPath: String)
18
+
19
+ /// No transcoding needed, original file is compatible
20
+ case notNeeded(originalPath: String)
21
+
22
+ /// Transcoding failed with error
23
+ case error(Error)
24
+ }
25
+
26
+ // MARK: - Public Methods
27
+
28
+ /// Checks if a video needs transcoding for effect compatibility.
29
+ ///
30
+ /// - Parameter videoPath: Path to the video file
31
+ /// - Returns: True if transcoding is needed
32
+ static func needsTranscoding(_ videoPath: String) async -> Bool {
33
+ let formatInfo = await MediaInfoExtractor.getVideoFormatInfo(videoPath)
34
+ let needsTranscode = formatInfo.needsTranscodingForEffects()
35
+
36
+ PluginLog.print(
37
+ "🔍 Video transcoding check: path=\(videoPath), "
38
+ + "isHevc=\(formatInfo.isHevc), bitDepth=\(formatInfo.bitDepth), "
39
+ + "isHdr=\(formatInfo.isHdr), needsTranscoding=\(needsTranscode)")
40
+
41
+ return needsTranscode
42
+ }
43
+
44
+ /// Transcodes a video to H.264 8-bit SDR format for effect compatibility.
45
+ ///
46
+ /// Uses HDR → SDR tonemapping to convert 10-bit HDR to 8-bit SDR,
47
+ /// which allows proper GPU effect processing.
48
+ ///
49
+ /// - Parameter videoPath: Path to the input video
50
+ /// - Returns: TranscodeResult indicating success, not-needed, or error
51
+ static func transcodeToH264(_ videoPath: String) async -> TranscodeResult {
52
+ // Check if transcoding is needed
53
+ guard await needsTranscoding(videoPath) else {
54
+ PluginLog.print("✅ No transcoding needed for: \(videoPath)")
55
+ return .notNeeded(originalPath: videoPath)
56
+ }
57
+
58
+ PluginLog.print(
59
+ "🎬 Starting HEVC 10-bit HDR → H.264 8-bit SDR transcoding for: \(videoPath)")
60
+
61
+ let inputURL = URL(fileURLWithPath: videoPath)
62
+ let outputURL = FileManager.default.temporaryDirectory
63
+ .appendingPathComponent("transcoded_\(Int(Date().timeIntervalSince1970 * 1000)).mp4")
64
+
65
+ do {
66
+ try await transcodeVideo(from: inputURL, to: outputURL)
67
+
68
+ // Verify output
69
+ let outputInfo = await MediaInfoExtractor.getVideoFormatInfo(outputURL.path)
70
+ PluginLog.print("✅ Transcoding completed: \(outputURL.path)")
71
+ PluginLog.print(
72
+ " Output: isHevc=\(outputInfo.isHevc), bitDepth=\(outputInfo.bitDepth), isHdr=\(outputInfo.isHdr)"
73
+ )
74
+
75
+ return .success(outputPath: outputURL.path)
76
+
77
+ } catch {
78
+ PluginLog.print("❌ Transcoding failed: \(error.localizedDescription)")
79
+ try? FileManager.default.removeItem(at: outputURL)
80
+ return .error(error)
81
+ }
82
+ }
83
+
84
+ /// Transcodes multiple video clips if needed.
85
+ ///
86
+ /// A clip that cannot be transcoded keeps its original path: the render then
87
+ /// runs on the HDR source, which is worse than a transcode but better than no
88
+ /// render at all. A *cancelled* job is the one failure that must not degrade
89
+ /// that way — it would build the whole composition on exactly the source the
90
+ /// pre-transcode exists to avoid, only to be thrown away — so it throws
91
+ /// instead, after removing the files already written.
92
+ ///
93
+ /// - Parameter inputPaths: List of input video paths
94
+ /// - Returns: Dictionary mapping original path to transcoded path (or original if no transcoding needed)
95
+ /// - Throws: `CancellationError` if the job was cancelled mid-transcode.
96
+ static func transcodeClipsIfNeeded(_ inputPaths: [String]) async throws -> [String: String] {
97
+ var result: [String: String] = [:]
98
+ var produced: [String] = []
99
+
100
+ for inputPath in inputPaths {
101
+ switch await transcodeToH264(inputPath) {
102
+ case .success(let outputPath):
103
+ result[inputPath] = outputPath
104
+ produced.append(outputPath)
105
+ case .notNeeded(let originalPath):
106
+ result[inputPath] = originalPath
107
+ case .error(let error):
108
+ if error is CancellationError || Task.isCancelled {
109
+ cleanupTranscodedFiles(produced)
110
+ throw CancellationError()
111
+ }
112
+ PluginLog.print("⚠️ Transcoding failed for \(inputPath), using original")
113
+ result[inputPath] = inputPath
114
+ }
115
+ }
116
+
117
+ return result
118
+ }
119
+
120
+ /// Cleans up transcoded temporary files.
121
+ ///
122
+ /// - Parameter transcodedPaths: Collection of transcoded file paths to delete
123
+ static func cleanupTranscodedFiles(_ transcodedPaths: [String]) {
124
+ for path in transcodedPaths {
125
+ if path.contains("transcoded_") {
126
+ do {
127
+ try FileManager.default.removeItem(atPath: path)
128
+ PluginLog.print("🗑️ Cleaned up transcoded file: \(path)")
129
+ } catch {
130
+ PluginLog.print("⚠️ Failed to clean up \(path): \(error.localizedDescription)")
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ // MARK: - Private Methods
137
+
138
+ /// Performs the actual video transcoding using AVAssetExportSession.
139
+ /// This is simpler and more reliable than AVAssetWriter for basic transcoding.
140
+ private static func transcodeVideo(from inputURL: URL, to outputURL: URL) async throws {
141
+ let asset = AVURLAsset(
142
+ url: inputURL,
143
+ options: [
144
+ AVURLAssetPreferPreciseDurationAndTimingKey: true
145
+ ])
146
+
147
+ guard
148
+ let exportSession = AVAssetExportSession(
149
+ asset: asset, presetName: AVAssetExportPresetHighestQuality)
150
+ else {
151
+ throw NSError(
152
+ domain: "VideoTranscoder", code: 1,
153
+ userInfo: [NSLocalizedDescriptionKey: "Failed to create export session"])
154
+ }
155
+
156
+ exportSession.shouldOptimizeForNetworkUse = true
157
+
158
+ // Create video composition for HDR → SDR conversion
159
+ let videoTrack: AVAssetTrack
160
+
161
+ if #available(iOS 15.0, macOS 13.0, *) {
162
+ let videoTracks = try await asset.loadTracks(withMediaType: .video)
163
+ guard let vTrack = videoTracks.first else {
164
+ throw NSError(
165
+ domain: "VideoTranscoder", code: 2,
166
+ userInfo: [NSLocalizedDescriptionKey: "No video track found"]
167
+ )
168
+ }
169
+ videoTrack = vTrack
170
+ } else {
171
+ guard let vTrack = asset.tracks(withMediaType: .video).first else {
172
+ throw NSError(
173
+ domain: "VideoTranscoder", code: 2,
174
+ userInfo: [NSLocalizedDescriptionKey: "No video track found"]
175
+ )
176
+ }
177
+ videoTrack = vTrack
178
+ }
179
+
180
+ // Get video properties
181
+ let naturalSize: CGSize
182
+ let preferredTransform: CGAffineTransform
183
+ let nominalFrameRate: Float
184
+
185
+ if #available(iOS 15.0, macOS 13.0, *) {
186
+ naturalSize = try await videoTrack.load(.naturalSize)
187
+ preferredTransform = try await videoTrack.load(.preferredTransform)
188
+ nominalFrameRate = try await videoTrack.load(.nominalFrameRate)
189
+ } else {
190
+ naturalSize = videoTrack.naturalSize
191
+ preferredTransform = videoTrack.preferredTransform
192
+ nominalFrameRate = videoTrack.nominalFrameRate
193
+ }
194
+
195
+ // Calculate render size accounting for rotation
196
+ let renderSize = calculateOutputSize(
197
+ naturalSize: naturalSize, transform: preferredTransform)
198
+
199
+ let processImage: (CIImage) -> CIImage = { sourceImage in
200
+ var image = sourceImage.clampedToExtent()
201
+
202
+ if let colorMatrix = CIFilter(name: "CIColorMatrix") {
203
+ colorMatrix.setValue(image, forKey: kCIInputImageKey)
204
+ colorMatrix.setValue(CIVector(x: 1, y: 0, z: 0, w: 0), forKey: "inputRVector")
205
+ colorMatrix.setValue(CIVector(x: 0, y: 1, z: 0, w: 0), forKey: "inputGVector")
206
+ colorMatrix.setValue(CIVector(x: 0, y: 1, z: 0, w: 0), forKey: "inputBVector")
207
+ colorMatrix.setValue(CIVector(x: 0, y: 0, z: 0, w: 1), forKey: "inputAVector")
208
+
209
+ if let output = colorMatrix.outputImage {
210
+ image = output
211
+ }
212
+ }
213
+
214
+ return image.cropped(to: CGRect(origin: .zero, size: renderSize))
215
+ }
216
+
217
+ if #available(iOS 26.0, macOS 26.0, *) {
218
+ let videoComposition = try await AVVideoComposition(applyingFiltersTo: asset) {
219
+ params in
220
+ let cropped = processImage(params.sourceImage)
221
+ return AVCIImageFilteringResult(resultImage: cropped, ciContext: nil)
222
+ }
223
+ exportSession.videoComposition = videoComposition
224
+
225
+ } else {
226
+ // Fallback block for older OS versions
227
+ let videoComposition = AVMutableVideoComposition(asset: asset) { request in
228
+ let cropped = processImage(request.sourceImage)
229
+ request.finish(with: cropped, context: nil)
230
+ }
231
+
232
+ videoComposition.renderSize = renderSize
233
+ videoComposition.frameDuration = CMTime(
234
+ value: 1, timescale: CMTimeScale(nominalFrameRate))
235
+
236
+ #if os(iOS)
237
+ if #available(iOS 14.0, *) {
238
+ videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2
239
+ videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2
240
+ videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2
241
+ }
242
+ #else // macOS branch
243
+ videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2
244
+ videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2
245
+ videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2
246
+ #endif
247
+
248
+ exportSession.videoComposition = videoComposition
249
+ }
250
+
251
+ PluginLog.print("🎬 Transcoding with AVAssetExportSession...")
252
+ PluginLog.print(" Input size: \(naturalSize), Output size: \(renderSize)")
253
+
254
+ try await ExportSessionDriver.run(
255
+ exportSession, to: outputURL, as: .mp4, label: "Transcode",
256
+ failureDomain: "VideoTranscoder")
257
+
258
+ PluginLog.print("✅ Transcoding completed successfully")
259
+ }
260
+
261
+ /// Calculates output size accounting for rotation.
262
+ private static func calculateOutputSize(naturalSize: CGSize, transform: CGAffineTransform)
263
+ -> CGSize
264
+ {
265
+ let rotationAngle = atan2(transform.b, transform.a)
266
+ let radians = abs(rotationAngle)
267
+
268
+ // Check if rotation is ~90° or ~270°
269
+ if radians > .pi / 4 && radians < 3 * .pi / 4 {
270
+ return CGSize(width: naturalSize.height, height: naturalSize.width)
271
+ }
272
+ return naturalSize
273
+ }
274
+
275
+ /// Calculates appropriate bitrate based on resolution and frame rate (Mac optimization legacy).
276
+ private static func calculateBitrate(size: CGSize, frameRate: Float) -> Int {
277
+ let pixels = Int(size.width * size.height)
278
+ let fps = max(24, min(60, Int(frameRate)))
279
+
280
+ // Roughly 0.1 bits per pixel per frame for H.264 High profile
281
+ return max(2_000_000, pixels * fps / 10)
282
+ }
283
+ }