@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.
- package/.prettierrc +8 -0
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/android/build.gradle +27 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/videoeditor/ColorMatrixEffect.kt +25 -0
- package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorModule.kt +61 -0
- package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorView.kt +109 -0
- package/android/src/main/java/expo/modules/videoeditor/ExportRunner.kt +302 -0
- package/android/src/main/java/expo/modules/videoeditor/GifOverlay.kt +27 -0
- package/android/src/main/java/expo/modules/videoeditor/Records.kt +44 -0
- package/eslint.config.cjs +16 -0
- package/expo-module.config.json +9 -0
- package/ios/AudioMixBuilder.swift +66 -0
- package/ios/ColorMatrixFilter.swift +24 -0
- package/ios/ExpoVideoEditor.podspec +23 -0
- package/ios/ExpoVideoEditorModule.swift +55 -0
- package/ios/ExpoVideoEditorView.swift +144 -0
- package/ios/ExportRunner.swift +135 -0
- package/ios/FrameComposer.swift +94 -0
- package/ios/ImageVideoWriter.swift +144 -0
- package/ios/Records.swift +41 -0
- package/package.json +36 -0
- package/src/components/EditorRoot.tsx +180 -0
- package/src/components/ExportOverlay.tsx +44 -0
- package/src/components/PreviewSurface.tsx +49 -0
- package/src/components/VideoEditor.tsx +51 -0
- package/src/components/audio/AudioPanel.tsx +69 -0
- package/src/components/audio/AudioScrubber.tsx +177 -0
- package/src/components/audio/Slider.tsx +60 -0
- package/src/components/filters/FilterCarousel.tsx +44 -0
- package/src/components/giphy/GiphyGrid.tsx +48 -0
- package/src/components/giphy/GiphyPicker.tsx +112 -0
- package/src/components/overlays/GifOverlayView.tsx +53 -0
- package/src/components/overlays/OverlayLayer.tsx +24 -0
- package/src/components/overlays/TextEditorSheet.tsx +135 -0
- package/src/components/overlays/TextOverlayView.tsx +61 -0
- package/src/components/timeline/ThumbnailStrip.tsx +63 -0
- package/src/components/timeline/TrimTimeline.tsx +152 -0
- package/src/errors.ts +33 -0
- package/src/filters/presets.ts +84 -0
- package/src/hooks/useAudioPreview.ts +94 -0
- package/src/hooks/useExport.ts +41 -0
- package/src/hooks/useOverlayGestures.ts +90 -0
- package/src/index.ts +29 -0
- package/src/native/EditorVideoView.tsx +27 -0
- package/src/native/VideoEditorModule.ts +51 -0
- package/src/overlays/captureRegistry.ts +12 -0
- package/src/services/demoCatalog.ts +30 -0
- package/src/services/exportService.ts +199 -0
- package/src/services/feedfmCatalog.ts +44 -0
- package/src/services/giphyClient.ts +69 -0
- package/src/services/mediaCache.ts +18 -0
- package/src/services/musicCatalog.ts +23 -0
- package/src/state/store.ts +200 -0
- package/src/theme.ts +26 -0
- package/src/types.ts +104 -0
- 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,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
|
+
}
|