239 lines
7.7 KiB
Swift
239 lines
7.7 KiB
Swift
//
|
|
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import AVFoundation
|
|
import SignalServiceKit
|
|
|
|
protocol VideoPlaybackState {
|
|
var isPlaying: Bool { get }
|
|
var currentTimeSeconds: TimeInterval { get }
|
|
}
|
|
|
|
protocol VideoEditorDataSource: AnyObject {
|
|
var untrimmedDurationSeconds: TimeInterval { get }
|
|
var trimmedStartSeconds: TimeInterval { get }
|
|
var trimmedEndSeconds: TimeInterval { get }
|
|
var canBeTrimmed: Bool { get }
|
|
var isTrimmed: Bool { get }
|
|
}
|
|
|
|
/**
|
|
* Coordinate data transfer between VideoEditorView and VideoTimelineView
|
|
*/
|
|
class VideoAttachmentPrepViewController: AttachmentPrepViewController {
|
|
|
|
private let model: VideoEditorModel
|
|
|
|
private lazy var editorView = VideoEditorView(model: model, delegate: self, dataSource: self, viewControllerProvider: self)
|
|
|
|
private lazy var timelineView: VideoTimelineView = {
|
|
let timelineView = VideoTimelineView()
|
|
timelineView.dataSource = self
|
|
timelineView.delegate = self
|
|
return timelineView
|
|
}()
|
|
|
|
override init?(attachmentApprovalItem: AttachmentApprovalItem) {
|
|
guard let videoEditorModel = attachmentApprovalItem.videoEditorModel else {
|
|
owsFailDebug("videoEditorModel is empty.")
|
|
return nil
|
|
}
|
|
|
|
self.model = videoEditorModel
|
|
|
|
super.init(attachmentApprovalItem: attachmentApprovalItem)
|
|
|
|
model.add(observer: self)
|
|
}
|
|
|
|
override var contentView: UIView {
|
|
editorView
|
|
}
|
|
|
|
override var toolbarSupplementaryView: UIView? {
|
|
timelineView
|
|
}
|
|
|
|
override func prepareContentView() {
|
|
editorView.configureSubviews()
|
|
generateThumbnailsAsync()
|
|
}
|
|
|
|
override func prepareToMoveOffscreen() {
|
|
editorView.pauseIfPlaying()
|
|
}
|
|
|
|
override public var canSaveMedia: Bool {
|
|
if model.needsRender {
|
|
return true
|
|
}
|
|
return super.canSaveMedia
|
|
}
|
|
|
|
private(set) var videoThumbnails: [UIImage]?
|
|
private var shouldResumeVideoPlaybackOnScrubbingEnd = false
|
|
}
|
|
|
|
extension VideoAttachmentPrepViewController: VideoEditorViewDelegate {
|
|
|
|
func videoEditorViewPlaybackTimeDidChange(_ videoEditorView: VideoEditorView) {
|
|
timelineView.updateCursorPosition()
|
|
timelineView.updateTimeBubble()
|
|
}
|
|
}
|
|
|
|
extension VideoAttachmentPrepViewController: VideoEditorDataSource {
|
|
|
|
var untrimmedDurationSeconds: TimeInterval {
|
|
return model.untrimmedDurationSeconds
|
|
}
|
|
|
|
var trimmedStartSeconds: TimeInterval {
|
|
return model.trimmedStartSeconds
|
|
}
|
|
|
|
var trimmedEndSeconds: TimeInterval {
|
|
return model.trimmedEndSeconds
|
|
}
|
|
|
|
var canBeTrimmed: Bool {
|
|
return model.canBeTrimmed
|
|
}
|
|
|
|
var isTrimmed: Bool {
|
|
return model.isTrimmed
|
|
}
|
|
}
|
|
|
|
extension VideoAttachmentPrepViewController: VideoPlaybackState {
|
|
|
|
var isPlaying: Bool {
|
|
return editorView.isPlaying
|
|
}
|
|
|
|
var currentTimeSeconds: TimeInterval {
|
|
return editorView.currentTimeSeconds
|
|
}
|
|
}
|
|
|
|
extension VideoAttachmentPrepViewController: VideoTimelineViewDataSource {
|
|
|
|
var videoAspectRatio: CGSize {
|
|
return model.displaySize
|
|
}
|
|
|
|
private func generateThumbnailsAsync() {
|
|
let model = self.model
|
|
let videoAspectRatio = videoAspectRatio
|
|
let untrimmedDurationSeconds = self.untrimmedDurationSeconds
|
|
|
|
firstly {
|
|
VideoAttachmentPrepViewController.thumbnails(forVideoAtPath: model.srcVideoPath,
|
|
aspectRatio: videoAspectRatio,
|
|
thumbnailHeight: VideoTimelineView.preferredHeight,
|
|
untrimmedDurationSeconds: untrimmedDurationSeconds)
|
|
}.done(on: DispatchQueue.main) { [weak self] (thumbnails: [UIImage]) -> Void in
|
|
guard let self = self else {
|
|
return
|
|
}
|
|
self.videoThumbnails = thumbnails
|
|
self.timelineView.updateThumbnailView()
|
|
}.catch { error in
|
|
owsFailDebug("Error: \(error)")
|
|
}
|
|
}
|
|
|
|
private class func thumbnails(forVideoAtPath videoPath: String,
|
|
aspectRatio: CGSize,
|
|
thumbnailHeight: CGFloat,
|
|
untrimmedDurationSeconds: TimeInterval) -> Promise<[UIImage]> {
|
|
AssertIsOnMainThread()
|
|
|
|
let contextSize = CurrentAppContext().frame.size
|
|
let screenScale = UIScreen.main.scale
|
|
|
|
return DispatchQueue.global().async(.promise) {
|
|
// We generate enough thumbnails for the worst case (full-screen landscape)
|
|
// to avoid the complexity of regeneration.
|
|
let contextMaxDimension = max(contextSize.width, contextSize.height)
|
|
let thumbnailWidth = floor(thumbnailHeight * aspectRatio.width / aspectRatio.height)
|
|
let thumbnailCount = UInt(ceil(contextMaxDimension / thumbnailWidth))
|
|
|
|
let maxThumbnailSize = max(thumbnailWidth, thumbnailHeight) * screenScale
|
|
|
|
let url = URL(fileURLWithPath: videoPath)
|
|
let asset = AVURLAsset(url: url, options: nil)
|
|
let generator = AVAssetImageGenerator(asset: asset)
|
|
generator.maximumSize = CGSize(square: maxThumbnailSize)
|
|
generator.appliesPreferredTrackTransform = true
|
|
var thumbnails = [UIImage]()
|
|
for index in 0..<thumbnailCount {
|
|
let thumbnailAlpha = Double(index) / Double(thumbnailCount - 1)
|
|
let thumbnailTimeSeconds = thumbnailAlpha * untrimmedDurationSeconds
|
|
let thumbnailCMTime = CMTime(seconds: thumbnailTimeSeconds, preferredTimescale: 1000)
|
|
let cgImage = try generator.copyCGImage(at: thumbnailCMTime, actualTime: nil)
|
|
let thumbnail = UIImage(cgImage: cgImage, scale: 1, orientation: .up)
|
|
thumbnails.append(thumbnail)
|
|
}
|
|
return thumbnails
|
|
}
|
|
}
|
|
}
|
|
|
|
extension VideoAttachmentPrepViewController: VideoTimelineViewDelegate {
|
|
|
|
func videoTimelineViewDidBeginTrimming(_ view: VideoTimelineView) {
|
|
editorView.pauseIfPlaying()
|
|
editorView.isTrimmingVideo = true
|
|
}
|
|
|
|
func videoTimelineView(_ view: VideoTimelineView, didTrimBeginningTo seconds: TimeInterval) {
|
|
model.trimToStartSeconds(seconds)
|
|
editorView.seek(toSeconds: seconds)
|
|
}
|
|
|
|
func videoTimelineView(_ view: VideoTimelineView, didTrimEndTo seconds: TimeInterval) {
|
|
model.trimToEndSeconds(seconds)
|
|
editorView.seek(toSeconds: seconds)
|
|
}
|
|
|
|
func videoTimelineViewDidEndTrimming(_ view: VideoTimelineView) {
|
|
editorView.isTrimmingVideo = false
|
|
editorView.ensureSeekReflectsTrimming()
|
|
|
|
if model.needsRender {
|
|
_ = model.ensureCurrentRender()
|
|
}
|
|
}
|
|
|
|
func videoTimelineViewWillBeginScrubbing(_ view: VideoTimelineView) {
|
|
// Pause playback during scrubbing.
|
|
shouldResumeVideoPlaybackOnScrubbingEnd = editorView.pauseIfPlaying()
|
|
}
|
|
|
|
func videoTimelineView(_ view: VideoTimelineView, didScrubTo seconds: TimeInterval) {
|
|
editorView.seek(toSeconds: seconds)
|
|
}
|
|
|
|
func videoTimelineViewDidEndScrubbing(_ view: VideoTimelineView) {
|
|
if shouldResumeVideoPlaybackOnScrubbingEnd {
|
|
editorView.playVideo()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension VideoAttachmentPrepViewController: VideoEditorModelObserver {
|
|
|
|
func videoEditorModelDidChange(_ model: VideoEditorModel) {
|
|
timelineView.updateContents()
|
|
}
|
|
}
|
|
|
|
extension VideoAttachmentPrepViewController: VideoEditorViewControllerProviding {
|
|
|
|
func viewController(forVideoEditorView videoEditorView: VideoEditorView) -> UIViewController {
|
|
return self
|
|
}
|
|
}
|