TM-SGNL-iOS/SignalUI/VideoEditor/VideoAttachmentPrepViewController.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

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
}
}