@igorpache/expo-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 (58) hide show
  1. package/.prettierrc +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +117 -0
  4. package/android/build.gradle +27 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/expo/modules/videoeditor/ColorMatrixEffect.kt +25 -0
  7. package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorModule.kt +61 -0
  8. package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorView.kt +109 -0
  9. package/android/src/main/java/expo/modules/videoeditor/ExportRunner.kt +302 -0
  10. package/android/src/main/java/expo/modules/videoeditor/GifOverlay.kt +27 -0
  11. package/android/src/main/java/expo/modules/videoeditor/Records.kt +44 -0
  12. package/eslint.config.cjs +16 -0
  13. package/expo-module.config.json +9 -0
  14. package/ios/AudioMixBuilder.swift +66 -0
  15. package/ios/ColorMatrixFilter.swift +24 -0
  16. package/ios/ExpoVideoEditor.podspec +23 -0
  17. package/ios/ExpoVideoEditorModule.swift +55 -0
  18. package/ios/ExpoVideoEditorView.swift +144 -0
  19. package/ios/ExportRunner.swift +135 -0
  20. package/ios/FrameComposer.swift +94 -0
  21. package/ios/ImageVideoWriter.swift +144 -0
  22. package/ios/Records.swift +41 -0
  23. package/package.json +36 -0
  24. package/src/components/EditorRoot.tsx +180 -0
  25. package/src/components/ExportOverlay.tsx +44 -0
  26. package/src/components/PreviewSurface.tsx +49 -0
  27. package/src/components/VideoEditor.tsx +51 -0
  28. package/src/components/audio/AudioPanel.tsx +69 -0
  29. package/src/components/audio/AudioScrubber.tsx +177 -0
  30. package/src/components/audio/Slider.tsx +60 -0
  31. package/src/components/filters/FilterCarousel.tsx +44 -0
  32. package/src/components/giphy/GiphyGrid.tsx +48 -0
  33. package/src/components/giphy/GiphyPicker.tsx +112 -0
  34. package/src/components/overlays/GifOverlayView.tsx +53 -0
  35. package/src/components/overlays/OverlayLayer.tsx +24 -0
  36. package/src/components/overlays/TextEditorSheet.tsx +135 -0
  37. package/src/components/overlays/TextOverlayView.tsx +61 -0
  38. package/src/components/timeline/ThumbnailStrip.tsx +63 -0
  39. package/src/components/timeline/TrimTimeline.tsx +152 -0
  40. package/src/errors.ts +33 -0
  41. package/src/filters/presets.ts +84 -0
  42. package/src/hooks/useAudioPreview.ts +94 -0
  43. package/src/hooks/useExport.ts +41 -0
  44. package/src/hooks/useOverlayGestures.ts +90 -0
  45. package/src/index.ts +29 -0
  46. package/src/native/EditorVideoView.tsx +27 -0
  47. package/src/native/VideoEditorModule.ts +51 -0
  48. package/src/overlays/captureRegistry.ts +12 -0
  49. package/src/services/demoCatalog.ts +30 -0
  50. package/src/services/exportService.ts +199 -0
  51. package/src/services/feedfmCatalog.ts +44 -0
  52. package/src/services/giphyClient.ts +69 -0
  53. package/src/services/mediaCache.ts +18 -0
  54. package/src/services/musicCatalog.ts +23 -0
  55. package/src/state/store.ts +200 -0
  56. package/src/theme.ts +26 -0
  57. package/src/types.ts +104 -0
  58. package/tsconfig.json +28 -0
@@ -0,0 +1,27 @@
1
+ package expo.modules.videoeditor
2
+
3
+ import android.graphics.Bitmap
4
+ import androidx.media3.common.util.UnstableApi
5
+ import androidx.media3.effect.BitmapOverlay
6
+ import androidx.media3.common.OverlaySettings
7
+
8
+ @UnstableApi
9
+ class GifOverlay(
10
+ private val frames: List<Bitmap>,
11
+ private val frameDurationsUs: LongArray,
12
+ private val settings: OverlaySettings,
13
+ ) : BitmapOverlay() {
14
+ private val totalUs = frameDurationsUs.sum().coerceAtLeast(1)
15
+
16
+ override fun getBitmap(presentationTimeUs: Long): Bitmap {
17
+ val t = presentationTimeUs % totalUs
18
+ var acc = 0L
19
+ for (i in frames.indices) {
20
+ acc += frameDurationsUs[i]
21
+ if (t < acc) return frames[i]
22
+ }
23
+ return frames.last()
24
+ }
25
+
26
+ override fun getOverlaySettings(presentationTimeUs: Long): OverlaySettings = settings
27
+ }
@@ -0,0 +1,44 @@
1
+ package expo.modules.videoeditor
2
+
3
+ import expo.modules.kotlin.records.Field
4
+ import expo.modules.kotlin.records.Record
5
+
6
+ class VideoSourceRecord : Record {
7
+ @Field val uri: String = ""
8
+ }
9
+
10
+ class SeekRecord : Record {
11
+ @Field val seq: Int = 0
12
+ @Field val positionMs: Int = 0
13
+ }
14
+
15
+ class OverlayRecord : Record {
16
+ @Field val uri: String = ""
17
+ @Field val animated: Boolean = false
18
+ @Field val centerX: Double = 0.5
19
+ @Field val centerY: Double = 0.5
20
+ @Field val widthFraction: Double = 0.0
21
+ @Field val heightFraction: Double = 0.0
22
+ @Field val rotationDeg: Double = 0.0
23
+ }
24
+
25
+ class AudioRecord : Record {
26
+ @Field val uri: String = ""
27
+ @Field val trimStartMs: Int = 0
28
+ @Field val trimEndMs: Int = 0
29
+ @Field val volume: Double = 1.0
30
+ @Field val loop: Boolean = true
31
+ }
32
+
33
+ class ExportPayload : Record {
34
+ @Field val sourceUri: String = ""
35
+ @Field val startMs: Int = 0
36
+ @Field val endMs: Int = 0
37
+ @Field val imageDurationMs: Int = 0
38
+ @Field val resolution: Int = 0
39
+ @Field val bitrate: Int = 0
40
+ @Field val colorMatrix: List<Double> = emptyList()
41
+ @Field val overlays: List<OverlayRecord> = emptyList()
42
+ @Field val audio: AudioRecord? = null
43
+ @Field val originalVolume: Double = 1.0
44
+ }
@@ -0,0 +1,16 @@
1
+ const { defineConfig } = require('eslint/config');
2
+ const universe = require('eslint-config-universe/flat/native');
3
+ const universeWeb = require('eslint-config-universe/flat/web');
4
+
5
+ module.exports = defineConfig([
6
+ { ignores: ['build'] },
7
+ ...universe,
8
+ ...universeWeb,
9
+ {
10
+ rules: {
11
+ 'react-hooks/immutability': 'off',
12
+ 'react-hooks/refs': 'off',
13
+ 'react-hooks/set-state-in-effect': 'off',
14
+ },
15
+ },
16
+ ]);
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["ExpoVideoEditorModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.videoeditor.ExpoVideoEditorModule"]
8
+ }
9
+ }
@@ -0,0 +1,66 @@
1
+ import AVFoundation
2
+
3
+ enum AudioMixBuilder {
4
+ static func build(source: AVURLAsset, payload: ExportPayload) async throws -> (AVMutableComposition, AVMutableAudioMix) {
5
+ let composition = AVMutableComposition()
6
+
7
+ let trimStart = CMTime(value: CMTimeValue(payload.startMs), timescale: 1000)
8
+ let trimDuration: CMTime =
9
+ payload.endMs > payload.startMs
10
+ ? CMTime(value: CMTimeValue(payload.endMs - payload.startMs), timescale: 1000)
11
+ : try await source.load(.duration)
12
+ let trimRange = CMTimeRange(start: trimStart, duration: trimDuration)
13
+
14
+ if let srcVideo = try await source.loadTracks(withMediaType: .video).first,
15
+ let compVideo = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) {
16
+ try compVideo.insertTimeRange(trimRange, of: srcVideo, at: .zero)
17
+ compVideo.preferredTransform = try await srcVideo.load(.preferredTransform)
18
+ }
19
+
20
+ var parameters: [AVMutableAudioMixInputParameters] = []
21
+
22
+ if let srcAudio = try await source.loadTracks(withMediaType: .audio).first,
23
+ let compAudio = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) {
24
+ try compAudio.insertTimeRange(trimRange, of: srcAudio, at: .zero)
25
+ let p = AVMutableAudioMixInputParameters(track: compAudio)
26
+ p.setVolume(Float(payload.originalVolume), at: .zero)
27
+ parameters.append(p)
28
+ }
29
+
30
+ if let audio = payload.audio, let musicURL = fileURL(audio.uri) {
31
+ let musicAsset = AVURLAsset(url: musicURL)
32
+ if let musicTrack = try await musicAsset.loadTracks(withMediaType: .audio).first,
33
+ let compMusic = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) {
34
+ let segStart = CMTime(value: CMTimeValue(audio.trimStartMs), timescale: 1000)
35
+ let segEnd: CMTime =
36
+ audio.trimEndMs > audio.trimStartMs
37
+ ? CMTime(value: CMTimeValue(audio.trimEndMs), timescale: 1000)
38
+ : try await musicAsset.load(.duration)
39
+ let segDuration = CMTimeSubtract(segEnd, segStart)
40
+
41
+ if segDuration.seconds > 0 {
42
+ var inserted = CMTime.zero
43
+ while inserted < trimDuration {
44
+ let remaining = CMTimeSubtract(trimDuration, inserted)
45
+ let chunk = CMTimeMinimum(segDuration, remaining)
46
+ if chunk.seconds <= 0 { break }
47
+ try compMusic.insertTimeRange(CMTimeRange(start: segStart, duration: chunk), of: musicTrack, at: inserted)
48
+ inserted = CMTimeAdd(inserted, chunk)
49
+ if !audio.loop { break }
50
+ }
51
+ }
52
+ let p = AVMutableAudioMixInputParameters(track: compMusic)
53
+ p.setVolume(Float(audio.volume), at: .zero)
54
+ parameters.append(p)
55
+ }
56
+ }
57
+
58
+ let mix = AVMutableAudioMix()
59
+ mix.inputParameters = parameters
60
+ return (composition, mix)
61
+ }
62
+
63
+ private static func fileURL(_ uri: String) -> URL? {
64
+ uri.hasPrefix("/") ? URL(fileURLWithPath: uri) : (URL(string: uri) ?? URL(fileURLWithPath: uri))
65
+ }
66
+ }
@@ -0,0 +1,24 @@
1
+ import AVFoundation
2
+ import CoreImage
3
+
4
+ enum ColorMatrixFilter {
5
+ static func makeFilter(_ m: [Double]) -> CIFilter? {
6
+ guard m.count >= 20, let filter = CIFilter(name: "CIColorMatrix") else { return nil }
7
+ filter.setValue(CIVector(x: CGFloat(m[0]), y: CGFloat(m[1]), z: CGFloat(m[2]), w: CGFloat(m[3])), forKey: "inputRVector")
8
+ filter.setValue(CIVector(x: CGFloat(m[5]), y: CGFloat(m[6]), z: CGFloat(m[7]), w: CGFloat(m[8])), forKey: "inputGVector")
9
+ filter.setValue(CIVector(x: CGFloat(m[10]), y: CGFloat(m[11]), z: CGFloat(m[12]), w: CGFloat(m[13])), forKey: "inputBVector")
10
+ filter.setValue(CIVector(x: CGFloat(m[15]), y: CGFloat(m[16]), z: CGFloat(m[17]), w: CGFloat(m[18])), forKey: "inputAVector")
11
+ filter.setValue(CIVector(x: CGFloat(m[4]), y: CGFloat(m[9]), z: CGFloat(m[14]), w: CGFloat(m[19])), forKey: "inputBiasVector")
12
+ return filter
13
+ }
14
+
15
+ static func videoComposition(for asset: AVAsset, matrix: [Double]) -> AVVideoComposition? {
16
+ guard let filter = makeFilter(matrix) else { return nil }
17
+ return AVMutableVideoComposition(asset: asset) { request in
18
+ let source = request.sourceImage.clampedToExtent()
19
+ filter.setValue(source, forKey: kCIInputImageKey)
20
+ let output = filter.outputImage?.cropped(to: request.sourceImage.extent) ?? request.sourceImage
21
+ request.finish(with: output, context: nil)
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,23 @@
1
+ Pod::Spec.new do |s|
2
+ s.name = 'ExpoVideoEditor'
3
+ s.version = '0.1.0'
4
+ s.summary = 'High-performance Stories-style video editor for Expo/React Native'
5
+ s.description = 'Native video editing (trim, color filters, text/GIF overlays, audio mixing) built on AVFoundation with a hardware-encoded single-pass export.'
6
+ s.author = 'Gleidson Daniel'
7
+ s.homepage = 'https://github.com/GleidsonDaniel/expo-video-editor'
8
+ s.platforms = {
9
+ :ios => '16.4',
10
+ :tvos => '16.4'
11
+ }
12
+ s.source = { git: 'https://github.com/GleidsonDaniel/expo-video-editor.git' }
13
+ s.static_framework = true
14
+
15
+ s.dependency 'ExpoModulesCore'
16
+
17
+ # Swift/Objective-C compatibility
18
+ s.pod_target_xcconfig = {
19
+ 'DEFINES_MODULE' => 'YES',
20
+ }
21
+
22
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
23
+ end
@@ -0,0 +1,55 @@
1
+ import ExpoModulesCore
2
+
3
+ public class ExpoVideoEditorModule: Module {
4
+ private var exportRunner: ExportRunner?
5
+
6
+ public func definition() -> ModuleDefinition {
7
+ Name("ExpoVideoEditor")
8
+
9
+ Events("onExportProgress")
10
+
11
+ AsyncFunction("ping") { () -> String in
12
+ "ExpoVideoEditor 0.1.0 (ios)"
13
+ }
14
+
15
+ AsyncFunction("exportVideo") { (payload: ExportPayload, promise: Promise) in
16
+ let runner = ExportRunner()
17
+ self.exportRunner = runner
18
+ runner.export(
19
+ payload,
20
+ onProgress: { [weak self] progress in
21
+ self?.sendEvent("onExportProgress", ["progress": progress])
22
+ },
23
+ resolve: { result in promise.resolve(result) },
24
+ reject: { code, message in promise.reject(code, message) }
25
+ )
26
+ }
27
+
28
+ Function("cancelExport") {
29
+ self.exportRunner?.cancel()
30
+ }
31
+
32
+ View(ExpoVideoEditorView.self) {
33
+ Events("onLoad", "onProgress", "onEnded")
34
+
35
+ Prop("source") { (view: ExpoVideoEditorView, source: VideoSourceRecord) in
36
+ view.setSource(source.uri)
37
+ }
38
+ Prop("paused") { (view: ExpoVideoEditorView, paused: Bool) in
39
+ view.setPaused(paused)
40
+ }
41
+ Prop("loopStartMs") { (view: ExpoVideoEditorView, ms: Int) in
42
+ view.setLoopStart(ms)
43
+ }
44
+ Prop("loopEndMs") { (view: ExpoVideoEditorView, ms: Int) in
45
+ view.setLoopEnd(ms)
46
+ }
47
+ Prop("seek") { (view: ExpoVideoEditorView, seek: SeekRecord) in
48
+ view.seekTo(seq: seek.seq, positionMs: seek.positionMs)
49
+ }
50
+ Prop("colorMatrix") { (view: ExpoVideoEditorView, matrix: [Double]) in
51
+ view.setColorMatrix(matrix)
52
+ }
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,144 @@
1
+ import AVFoundation
2
+ import ExpoModulesCore
3
+ import UIKit
4
+
5
+ class ExpoVideoEditorView: ExpoView {
6
+ private let onLoad = EventDispatcher()
7
+ private let onProgress = EventDispatcher()
8
+ private let onEnded = EventDispatcher()
9
+
10
+ private let player = AVPlayer()
11
+ private let playerLayer = AVPlayerLayer()
12
+ private var timeObserver: Any?
13
+ private var statusObservation: NSKeyValueObservation?
14
+
15
+ private var loopStartMs: Int = 0
16
+ private var loopEndMs: Int = 0
17
+ private var lastSeekSeq: Int = -1
18
+ private var didEmitLoad = false
19
+ private var colorMatrix: [Double] = []
20
+
21
+ required init(appContext: AppContext? = nil) {
22
+ super.init(appContext: appContext)
23
+ clipsToBounds = true
24
+ backgroundColor = .black
25
+ playerLayer.player = player
26
+ playerLayer.videoGravity = .resizeAspectFill
27
+ layer.addSublayer(playerLayer)
28
+
29
+ let interval = CMTime(value: 1, timescale: 20)
30
+ timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
31
+ self?.onTick(time)
32
+ }
33
+ }
34
+
35
+ deinit {
36
+ if let observer = timeObserver {
37
+ player.removeTimeObserver(observer)
38
+ }
39
+ statusObservation?.invalidate()
40
+ }
41
+
42
+ override func layoutSubviews() {
43
+ super.layoutSubviews()
44
+ playerLayer.frame = bounds
45
+ }
46
+
47
+ func setSource(_ uri: String) {
48
+ guard let url = Self.urlFrom(uri) else { return }
49
+ didEmitLoad = false
50
+ let item = AVPlayerItem(url: url)
51
+ statusObservation?.invalidate()
52
+ statusObservation = item.observe(\.status, options: [.new]) { [weak self] observed, _ in
53
+ guard let self = self, observed.status == .readyToPlay, !self.didEmitLoad else { return }
54
+ self.didEmitLoad = true
55
+ self.emitLoad(item)
56
+ self.applyVideoComposition()
57
+ }
58
+ player.replaceCurrentItem(with: item)
59
+ applyVideoComposition()
60
+ player.play()
61
+ }
62
+
63
+ func setPaused(_ paused: Bool) {
64
+ paused ? player.pause() : player.play()
65
+ }
66
+
67
+ func setLoopStart(_ ms: Int) {
68
+ loopStartMs = ms
69
+ }
70
+
71
+ func setLoopEnd(_ ms: Int) {
72
+ loopEndMs = ms
73
+ }
74
+
75
+ func seekTo(seq: Int, positionMs: Int) {
76
+ guard seq != lastSeekSeq else { return }
77
+ lastSeekSeq = seq
78
+ let target = CMTime(value: CMTimeValue(max(0, positionMs)), timescale: 1000)
79
+ player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero)
80
+ }
81
+
82
+ func setColorMatrix(_ matrix: [Double]) {
83
+ colorMatrix = matrix
84
+ applyVideoComposition()
85
+ }
86
+
87
+ private func applyVideoComposition() {
88
+ guard let item = player.currentItem else { return }
89
+ if colorMatrix.count >= 20 {
90
+ item.videoComposition = ColorMatrixFilter.videoComposition(for: item.asset, matrix: colorMatrix)
91
+ } else {
92
+ item.videoComposition = nil
93
+ }
94
+ }
95
+
96
+ private func onTick(_ time: CMTime) {
97
+ let seconds = CMTimeGetSeconds(time)
98
+ guard seconds.isFinite else { return }
99
+ let posMs = Int(seconds * 1000)
100
+
101
+ let itemSeconds = player.currentItem.map { CMTimeGetSeconds($0.duration) } ?? 0
102
+ let itemDurMs = itemSeconds.isFinite ? Int(itemSeconds * 1000) : 0
103
+ let end = loopEndMs > 0 ? loopEndMs : itemDurMs
104
+ if end > 0 && posMs >= end {
105
+ let start = CMTime(value: CMTimeValue(max(0, loopStartMs)), timescale: 1000)
106
+ player.seek(to: start, toleranceBefore: .zero, toleranceAfter: .zero)
107
+ }
108
+ onProgress(["positionMs": posMs])
109
+ }
110
+
111
+ private func emitLoad(_ item: AVPlayerItem) {
112
+ Task {
113
+ let asset = item.asset
114
+ let durationSeconds = (try? await asset.load(.duration).seconds) ?? 0
115
+ let durationMs = durationSeconds.isFinite ? Int(durationSeconds * 1000) : 0
116
+
117
+ var width = 0
118
+ var height = 0
119
+ var rotationDeg = 0
120
+ if let track = try? await asset.loadTracks(withMediaType: .video).first {
121
+ let naturalSize = (try? await track.load(.naturalSize)) ?? .zero
122
+ let transform = (try? await track.load(.preferredTransform)) ?? .identity
123
+ let resolved = naturalSize.applying(transform)
124
+ width = Int(abs(resolved.width))
125
+ height = Int(abs(resolved.height))
126
+ rotationDeg = Int((atan2(transform.b, transform.a) * 180 / .pi).rounded())
127
+ }
128
+
129
+ await MainActor.run {
130
+ self.onLoad([
131
+ "durationMs": durationMs,
132
+ "width": width,
133
+ "height": height,
134
+ "rotationDeg": rotationDeg,
135
+ ])
136
+ }
137
+ }
138
+ }
139
+
140
+ static func urlFrom(_ uri: String) -> URL? {
141
+ if uri.hasPrefix("/") { return URL(fileURLWithPath: uri) }
142
+ return URL(string: uri)
143
+ }
144
+ }
@@ -0,0 +1,135 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ class ExportRunner {
5
+ private var session: AVAssetExportSession?
6
+ private var progressTimer: Timer?
7
+
8
+ func export(
9
+ _ payload: ExportPayload,
10
+ onProgress: @escaping (Double) -> Void,
11
+ resolve: @escaping (Any?) -> Void,
12
+ reject: @escaping (String, String) -> Void
13
+ ) {
14
+ Task {
15
+ do {
16
+ let source: AVURLAsset
17
+ if payload.imageDurationMs > 0 {
18
+ let stillURL = try await ImageVideoWriter.makeSilentVideo(
19
+ imageUri: payload.sourceUri,
20
+ durationMs: payload.imageDurationMs,
21
+ maxSide: payload.resolution
22
+ )
23
+ source = AVURLAsset(url: stillURL)
24
+ } else {
25
+ guard let url = Self.urlFrom(payload.sourceUri) else {
26
+ reject("SOURCE_LOAD", "Invalid source URI")
27
+ return
28
+ }
29
+ source = AVURLAsset(url: url)
30
+ }
31
+
32
+ let hasComposition = payload.colorMatrix.count >= 20 || !payload.overlays.isEmpty
33
+ let hasAudio = payload.audio != nil
34
+
35
+ let exportAsset: AVAsset
36
+ var audioMix: AVAudioMix?
37
+ var trimBaked = false
38
+ if hasAudio {
39
+ let (composition, mix) = try await AudioMixBuilder.build(source: source, payload: payload)
40
+ exportAsset = composition
41
+ audioMix = mix
42
+ trimBaked = true
43
+ } else {
44
+ exportAsset = source
45
+ }
46
+
47
+ let needsReencode = hasComposition || hasAudio
48
+ let preset = needsReencode ? AVAssetExportPresetHighestQuality : AVAssetExportPresetPassthrough
49
+ guard let session = AVAssetExportSession(asset: exportAsset, presetName: preset) else {
50
+ reject("EXPORT_FAILED", "Could not create export session")
51
+ return
52
+ }
53
+
54
+ let outURL = FileManager.default.temporaryDirectory
55
+ .appendingPathComponent("vieditor-export-\(UUID().uuidString).mp4")
56
+ session.outputURL = outURL
57
+ session.outputFileType = .mp4
58
+ session.shouldOptimizeForNetworkUse = true
59
+
60
+ if !trimBaked, payload.endMs > payload.startMs {
61
+ let start = CMTime(value: CMTimeValue(payload.startMs), timescale: 1000)
62
+ let duration = CMTime(value: CMTimeValue(payload.endMs - payload.startMs), timescale: 1000)
63
+ session.timeRange = CMTimeRange(start: start, duration: duration)
64
+ }
65
+ if hasComposition {
66
+ session.videoComposition = FrameComposer.videoComposition(
67
+ for: exportAsset,
68
+ matrix: payload.colorMatrix,
69
+ overlays: payload.overlays
70
+ )
71
+ }
72
+ if let audioMix = audioMix {
73
+ session.audioMix = audioMix
74
+ }
75
+ self.session = session
76
+
77
+ await MainActor.run {
78
+ self.progressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in
79
+ onProgress(Double(session.progress))
80
+ }
81
+ }
82
+
83
+ session.exportAsynchronously { [weak self] in
84
+ Task { @MainActor in
85
+ self?.progressTimer?.invalidate()
86
+ self?.progressTimer = nil
87
+ }
88
+ switch session.status {
89
+ case .completed:
90
+ onProgress(1.0)
91
+ Task { resolve(await Self.readResult(outURL)) }
92
+ case .cancelled:
93
+ reject("CANCELLED", "Export cancelled")
94
+ default:
95
+ reject("EXPORT_FAILED", session.error?.localizedDescription ?? "Export failed")
96
+ }
97
+ }
98
+ } catch {
99
+ reject("EXPORT_FAILED", error.localizedDescription)
100
+ }
101
+ }
102
+ }
103
+
104
+ func cancel() {
105
+ session?.cancelExport()
106
+ }
107
+
108
+ private static func readResult(_ url: URL) async -> [String: Any] {
109
+ let asset = AVURLAsset(url: url)
110
+ let durationSeconds = (try? await asset.load(.duration).seconds) ?? 0
111
+ let durationMs = durationSeconds.isFinite ? Int(durationSeconds * 1000) : 0
112
+
113
+ var width = 0
114
+ var height = 0
115
+ if let track = try? await asset.loadTracks(withMediaType: .video).first {
116
+ let naturalSize = (try? await track.load(.naturalSize)) ?? .zero
117
+ let transform = (try? await track.load(.preferredTransform)) ?? .identity
118
+ let resolved = naturalSize.applying(transform)
119
+ width = Int(abs(resolved.width))
120
+ height = Int(abs(resolved.height))
121
+ }
122
+
123
+ return [
124
+ "uri": url.absoluteString,
125
+ "width": width,
126
+ "height": height,
127
+ "durationMs": durationMs,
128
+ ]
129
+ }
130
+
131
+ static func urlFrom(_ uri: String) -> URL? {
132
+ if uri.hasPrefix("/") { return URL(fileURLWithPath: uri) }
133
+ return URL(string: uri)
134
+ }
135
+ }
@@ -0,0 +1,94 @@
1
+ import AVFoundation
2
+ import CoreImage
3
+ import ImageIO
4
+
5
+ enum FrameComposer {
6
+ private struct LoadedOverlay {
7
+ let frames: [CIImage]
8
+ let cumulative: [Double]
9
+ let total: Double
10
+ let rec: OverlayRecord
11
+
12
+ func image(at time: Double) -> CIImage {
13
+ if frames.count <= 1 || total <= 0 { return frames[0] }
14
+ let t = time.truncatingRemainder(dividingBy: total)
15
+ for (i, end) in cumulative.enumerated() where t < end { return frames[i] }
16
+ return frames[frames.count - 1]
17
+ }
18
+ }
19
+
20
+ static func videoComposition(
21
+ for asset: AVAsset,
22
+ matrix: [Double],
23
+ overlays: [OverlayRecord]
24
+ ) -> AVVideoComposition? {
25
+ let filter = ColorMatrixFilter.makeFilter(matrix)
26
+ let loaded = overlays.compactMap { load($0) }
27
+ if filter == nil && loaded.isEmpty { return nil }
28
+
29
+ return AVMutableVideoComposition(asset: asset) { request in
30
+ let extent = request.sourceImage.extent
31
+ var image = request.sourceImage.clampedToExtent()
32
+ if let filter = filter {
33
+ filter.setValue(image, forKey: kCIInputImageKey)
34
+ image = filter.outputImage?.cropped(to: extent) ?? image
35
+ }
36
+ let time = request.compositionTime.seconds
37
+ for overlay in loaded {
38
+ image = composite(overlay: overlay.image(at: time), over: image, frame: extent, rec: overlay.rec)
39
+ }
40
+ request.finish(with: image, context: nil)
41
+ }
42
+ }
43
+
44
+ private static func composite(overlay: CIImage, over base: CIImage, frame: CGRect, rec: OverlayRecord) -> CIImage {
45
+ let oe = overlay.extent
46
+ guard oe.width > 0, oe.height > 0, frame.width > 0, frame.height > 0 else { return base }
47
+ let sx = (rec.widthFraction * frame.width) / oe.width
48
+ let sy = (rec.heightFraction * frame.height) / oe.height
49
+ let cx = frame.minX + rec.centerX * frame.width
50
+ let cy = frame.minY + (1 - rec.centerY) * frame.height
51
+
52
+ let transform = CGAffineTransform(translationX: cx, y: cy)
53
+ .rotated(by: -rec.rotationDeg * .pi / 180)
54
+ .scaledBy(x: sx, y: sy)
55
+ .translatedBy(x: -oe.midX, y: -oe.midY)
56
+
57
+ return overlay.transformed(by: transform).composited(over: base)
58
+ }
59
+
60
+ private static func load(_ rec: OverlayRecord) -> LoadedOverlay? {
61
+ guard let url = fileURL(rec.uri) else { return nil }
62
+ if rec.animated, let source = CGImageSourceCreateWithURL(url as CFURL, nil) {
63
+ let count = CGImageSourceGetCount(source)
64
+ if count > 1 {
65
+ var frames: [CIImage] = []
66
+ var cumulative: [Double] = []
67
+ var acc = 0.0
68
+ for i in 0..<count {
69
+ guard let cg = CGImageSourceCreateImageAtIndex(source, i, nil) else { continue }
70
+ frames.append(CIImage(cgImage: cg))
71
+ acc += gifDelay(source, i)
72
+ cumulative.append(acc)
73
+ }
74
+ if !frames.isEmpty {
75
+ return LoadedOverlay(frames: frames, cumulative: cumulative, total: acc, rec: rec)
76
+ }
77
+ }
78
+ }
79
+ guard let image = CIImage(contentsOf: url) else { return nil }
80
+ return LoadedOverlay(frames: [image], cumulative: [0], total: 0, rec: rec)
81
+ }
82
+
83
+ private static func gifDelay(_ source: CGImageSource, _ index: Int) -> Double {
84
+ guard let props = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
85
+ let gif = props[kCGImagePropertyGIFDictionary] as? [CFString: Any] else { return 0.1 }
86
+ let unclamped = gif[kCGImagePropertyGIFUnclampedDelayTime] as? Double
87
+ let delay = unclamped ?? (gif[kCGImagePropertyGIFDelayTime] as? Double) ?? 0.1
88
+ return delay > 0 ? delay : 0.1
89
+ }
90
+
91
+ private static func fileURL(_ uri: String) -> URL? {
92
+ uri.hasPrefix("/") ? URL(fileURLWithPath: uri) : (URL(string: uri) ?? URL(fileURLWithPath: uri))
93
+ }
94
+ }