// // ZLEditVideoViewController.swift // ZLPhotoBrowser // // Created by long on 2020/8/30. // // Copyright (c) 2020 Long Zhang <495181165@qq.com> // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import UIKit import Photos public class ZLEditVideoViewController: UIViewController { private static let frameImageSize = CGSize(width: CGFloat(round(50.0 * 2.0 / 3.0)), height: 50.0) private let avAsset: AVAsset private let animateDismiss: Bool private lazy var cancelBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle(localLanguageTextValue(.cancel), for: .normal) btn.setTitleColor(.zl.bottomToolViewBtnNormalTitleColor, for: .normal) btn.titleLabel?.font = ZLLayout.bottomToolTitleFont btn.addTarget(self, action: #selector(cancelBtnClick), for: .touchUpInside) return btn }() private lazy var doneBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle(localLanguageTextValue(.done), for: .normal) btn.setTitleColor(.zl.bottomToolViewBtnNormalTitleColor, for: .normal) btn.titleLabel?.font = ZLLayout.bottomToolTitleFont btn.addTarget(self, action: #selector(doneBtnClick), for: .touchUpInside) btn.backgroundColor = .zl.bottomToolViewBtnNormalBgColor btn.layer.masksToBounds = true btn.layer.cornerRadius = ZLLayout.bottomToolBtnCornerRadius return btn }() private var timer: Timer? private lazy var playerLayer: AVPlayerLayer = { let layer = AVPlayerLayer() layer.videoGravity = .resizeAspect return layer }() private lazy var collectionView: UICollectionView = { let layout = ZLCollectionViewFlowLayout() layout.itemSize = ZLEditVideoViewController.frameImageSize layout.minimumLineSpacing = 0 layout.minimumInteritemSpacing = 0 layout.scrollDirection = .horizontal let view = UICollectionView(frame: .zero, collectionViewLayout: layout) view.backgroundColor = .clear view.delegate = self view.dataSource = self view.showsHorizontalScrollIndicator = false ZLEditVideoFrameImageCell.zl.register(view) return view }() private lazy var frameImageBorderView: ZLEditVideoFrameImageBorderView = { let view = ZLEditVideoFrameImageBorderView() view.isUserInteractionEnabled = false return view }() private lazy var leftSideView: UIImageView = { let view = UIImageView(image: .zl.getImage("zl_ic_left")) view.isUserInteractionEnabled = true return view }() private lazy var rightSideView: UIImageView = { let view = UIImageView(image: .zl.getImage("zl_ic_right")) view.isUserInteractionEnabled = true return view }() private lazy var leftSidePan: UIPanGestureRecognizer = { let pan = UIPanGestureRecognizer(target: self, action: #selector(leftSidePanAction(_:))) pan.delegate = self return pan }() private lazy var rightSidePan: UIPanGestureRecognizer = { let pan = UIPanGestureRecognizer(target: self, action: #selector(rightSidePanAction(_:))) pan.delegate = self return pan }() private lazy var indicator: UIView = { let view = UIView() view.backgroundColor = UIColor.white.withAlphaComponent(0.7) return view }() private var measureCount = 0 private lazy var interval: TimeInterval = { let assetDuration = round(self.avAsset.duration.seconds) return min(assetDuration, TimeInterval(ZLPhotoConfiguration.default().maxEditVideoTime)) / 10 }() private lazy var requestFrameImageQueue: OperationQueue = { let queue = OperationQueue() queue.maxConcurrentOperationCount = 10 return queue }() private lazy var avAssetRequestID = PHInvalidImageRequestID private lazy var videoRequestID = PHInvalidImageRequestID private var frameImageCache: [Int: UIImage] = [:] private var requestFailedFrameImageIndex: [Int] = [] private var shouldLayout = true private lazy var generator: AVAssetImageGenerator = { let g = AVAssetImageGenerator(asset: self.avAsset) g.maximumSize = CGSize(width: ZLEditVideoViewController.frameImageSize.width * 3, height: ZLEditVideoViewController.frameImageSize.height * 3) g.appliesPreferredTrackTransform = true g.requestedTimeToleranceBefore = .zero g.requestedTimeToleranceAfter = .zero g.apertureMode = .productionAperture return g }() @objc public var editFinishBlock: ((URL?) -> Void)? override public var prefersStatusBarHidden: Bool { return true } override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { return .portrait } deinit { zl_debugPrint("ZLEditVideoViewController deinit") cleanTimer() requestFrameImageQueue.cancelAllOperations() if avAssetRequestID > PHInvalidImageRequestID { PHImageManager.default().cancelImageRequest(avAssetRequestID) } if videoRequestID > PHInvalidImageRequestID { PHImageManager.default().cancelImageRequest(videoRequestID) } } /// initialize /// - Parameters: /// - avAsset: AVAsset对象,需要传入本地视频,网络视频不支持 /// - animateDismiss: 退出界面时是否显示dismiss动画 @objc public init(avAsset: AVAsset, animateDismiss: Bool = false) { self.avAsset = avAsset self.animateDismiss = animateDismiss super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override public func viewDidLoad() { super.viewDidLoad() setupUI() NotificationCenter.default.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) } override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) analysisAssetImages() } override public func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() guard shouldLayout else { return } shouldLayout = false zl_debugPrint("edit video layout subviews") var insets = UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0) if #available(iOS 11.0, *) { insets = self.view.safeAreaInsets } let btnH = ZLLayout.bottomToolBtnH let bottomBtnAndColSpacing: CGFloat = 20 let playerLayerY = insets.top + 20 let diffBottom = btnH + ZLEditVideoViewController.frameImageSize.height + bottomBtnAndColSpacing + insets.bottom + 30 playerLayer.frame = CGRect(x: 15, y: insets.top + 20, width: view.bounds.width - 30, height: view.bounds.height - playerLayerY - diffBottom) let cancelBtnW = localLanguageTextValue(.cancel).zl.boundingRect(font: ZLLayout.bottomToolTitleFont, limitSize: CGSize(width: CGFloat.greatestFiniteMagnitude, height: btnH)).width cancelBtn.frame = CGRect(x: 20, y: view.bounds.height - insets.bottom - btnH, width: cancelBtnW, height: btnH) let doneBtnW = localLanguageTextValue(.done).zl.boundingRect(font: ZLLayout.bottomToolTitleFont, limitSize: CGSize(width: CGFloat.greatestFiniteMagnitude, height: btnH)).width + 20 doneBtn.frame = CGRect(x: view.bounds.width - doneBtnW - 20, y: view.bounds.height - insets.bottom - btnH, width: doneBtnW, height: btnH) collectionView.frame = CGRect(x: 0, y: doneBtn.frame.minY - bottomBtnAndColSpacing - ZLEditVideoViewController.frameImageSize.height, width: view.bounds.width, height: ZLEditVideoViewController.frameImageSize.height) let frameViewW = ZLEditVideoViewController.frameImageSize.width * 10 frameImageBorderView.frame = CGRect(x: (view.bounds.width - frameViewW) / 2, y: collectionView.frame.minY, width: frameViewW, height: ZLEditVideoViewController.frameImageSize.height) // 左右拖动view let leftRightSideViewW = ZLEditVideoViewController.frameImageSize.width / 2 leftSideView.frame = CGRect(x: frameImageBorderView.frame.minX, y: collectionView.frame.minY, width: leftRightSideViewW, height: ZLEditVideoViewController.frameImageSize.height) let rightSideViewX = view.bounds.width - frameImageBorderView.frame.minX - leftRightSideViewW rightSideView.frame = CGRect(x: rightSideViewX, y: collectionView.frame.minY, width: leftRightSideViewW, height: ZLEditVideoViewController.frameImageSize.height) frameImageBorderView.validRect = frameImageBorderView.convert(clipRect(), from: view) } private func setupUI() { view.backgroundColor = .black view.layer.addSublayer(playerLayer) view.addSubview(collectionView) view.addSubview(frameImageBorderView) view.addSubview(indicator) view.addSubview(leftSideView) view.addSubview(rightSideView) view.addGestureRecognizer(leftSidePan) view.addGestureRecognizer(rightSidePan) collectionView.panGestureRecognizer.require(toFail: leftSidePan) collectionView.panGestureRecognizer.require(toFail: rightSidePan) rightSidePan.require(toFail: leftSidePan) view.addSubview(cancelBtn) view.addSubview(doneBtn) } @objc private func cancelBtnClick() { dismiss(animated: animateDismiss, completion: nil) } @objc private func doneBtnClick() { cleanTimer() let d = CGFloat(interval) * clipRect().width / ZLEditVideoViewController.frameImageSize.width if ZLPhotoConfiguration.Second(round(d)) < ZLPhotoConfiguration.default().minSelectVideoDuration { let message = String(format: localLanguageTextValue(.shorterThanMinVideoDuration), ZLPhotoConfiguration.default().minSelectVideoDuration) showAlertView(message, self) return } if ZLPhotoConfiguration.Second(round(d)) > ZLPhotoConfiguration.default().maxSelectVideoDuration { let message = String(format: localLanguageTextValue(.longerThanMaxVideoDuration), ZLPhotoConfiguration.default().maxSelectVideoDuration) showAlertView(message, self) return } // Max deviation is 0.01 if abs(d - round(CGFloat(avAsset.duration.seconds))) <= 0.01 { dismiss(animated: animateDismiss) { self.editFinishBlock?(nil) } return } let hud = ZLProgressHUD.show() ZLVideoManager.exportEditVideo(for: avAsset, range: getTimeRange()) { [weak self] url, error in hud.hide() if let er = error { showAlertView(er.localizedDescription, self) } else if url != nil { self?.dismiss(animated: self?.animateDismiss ?? false) { self?.editFinishBlock?(url) } } } } @objc private func leftSidePanAction(_ pan: UIPanGestureRecognizer) { let point = pan.location(in: view) if pan.state == .began { frameImageBorderView.layer.borderColor = UIColor(white: 1, alpha: 0.4).cgColor cleanTimer() } else if pan.state == .changed { let minX = frameImageBorderView.frame.minX let maxX = rightSideView.frame.minX - leftSideView.frame.width var frame = leftSideView.frame frame.origin.x = min(maxX, max(minX, point.x)) leftSideView.frame = frame frameImageBorderView.validRect = frameImageBorderView.convert(clipRect(), from: view) playerLayer.player?.seek(to: getStartTime(), toleranceBefore: .zero, toleranceAfter: .zero) } else if pan.state == .ended || pan.state == .cancelled { frameImageBorderView.layer.borderColor = UIColor.clear.cgColor startTimer() } } @objc private func rightSidePanAction(_ pan: UIPanGestureRecognizer) { let point = pan.location(in: view) if pan.state == .began { frameImageBorderView.layer.borderColor = UIColor(white: 1, alpha: 0.4).cgColor cleanTimer() } else if pan.state == .changed { let minX = leftSideView.frame.maxX let maxX = frameImageBorderView.frame.maxX - rightSideView.frame.width var frame = rightSideView.frame frame.origin.x = min(maxX, max(minX, point.x)) rightSideView.frame = frame frameImageBorderView.validRect = frameImageBorderView.convert(clipRect(), from: view) playerLayer.player?.seek(to: getStartTime(), toleranceBefore: .zero, toleranceAfter: .zero) } else if pan.state == .ended || pan.state == .cancelled { frameImageBorderView.layer.borderColor = UIColor.clear.cgColor startTimer() } } @objc private func appWillResignActive() { cleanTimer() indicator.layer.removeAllAnimations() } @objc private func appDidBecomeActive() { startTimer() } private func analysisAssetImages() { let duration = round(avAsset.duration.seconds) guard duration > 0 else { showFetchFailedAlert() return } let item = AVPlayerItem(asset: avAsset) let player = AVPlayer(playerItem: item) playerLayer.player = player startTimer() measureCount = Int(duration / interval) collectionView.reloadData() requestVideoMeasureFrameImage() } private func requestVideoMeasureFrameImage() { for i in 0.. CMTime { var rect = collectionView.convert(clipRect(), from: view) rect.origin.x -= frameImageBorderView.frame.minX let second = max(0, CGFloat(interval) * rect.minX / ZLEditVideoViewController.frameImageSize.width) return CMTimeMakeWithSeconds(Float64(second), preferredTimescale: avAsset.duration.timescale) } private func getTimeRange() -> CMTimeRange { let start = getStartTime() let d = CGFloat(interval) * clipRect().width / ZLEditVideoViewController.frameImageSize.width let duration = CMTimeMakeWithSeconds(Float64(d), preferredTimescale: avAsset.duration.timescale) return CMTimeRangeMake(start: start, duration: duration) } private func clipRect() -> CGRect { var frame = CGRect.zero frame.origin.x = leftSideView.frame.minX frame.origin.y = leftSideView.frame.minY frame.size.width = rightSideView.frame.maxX - frame.minX frame.size.height = leftSideView.frame.height return frame } private func showFetchFailedAlert() { let action = ZLCustomAlertAction(title: localLanguageTextValue(.ok), style: .default) { [weak self] _ in self?.dismiss(animated: false) } showAlertController(title: nil, message: localLanguageTextValue(.iCloudVideoLoadFaild), style: .alert, actions: [action], sender: self) } } extension ZLEditVideoViewController: UIGestureRecognizerDelegate { public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer == leftSidePan { let point = gestureRecognizer.location(in: view) let frame = leftSideView.frame let outerFrame = frame.inset(by: UIEdgeInsets(top: -20, left: -40, bottom: -20, right: -20)) return outerFrame.contains(point) } else if gestureRecognizer == rightSidePan { let point = gestureRecognizer.location(in: view) let frame = rightSideView.frame let outerFrame = frame.inset(by: UIEdgeInsets(top: -20, left: -20, bottom: -20, right: -40)) return outerFrame.contains(point) } return true } } extension ZLEditVideoViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { public func scrollViewDidScroll(_ scrollView: UIScrollView) { cleanTimer() playerLayer.player?.seek(to: getStartTime(), toleranceBefore: .zero, toleranceAfter: .zero) } public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { startTimer() } } public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { startTimer() } public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { let w = ZLEditVideoViewController.frameImageSize.width * 10 let leftRight = (collectionView.frame.width - w) / 2 return UIEdgeInsets(top: 0, left: leftRight, bottom: 0, right: leftRight) } public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return measureCount } public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ZLEditVideoFrameImageCell.zl.identifier, for: indexPath) as! ZLEditVideoFrameImageCell if let image = frameImageCache[indexPath.row] { cell.imageView.image = image } return cell } public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { if requestFailedFrameImageIndex.contains(indexPath.row) { let mes = TimeInterval(indexPath.row) * interval let time = CMTimeMakeWithSeconds(Float64(mes), preferredTimescale: avAsset.duration.timescale) let operation = ZLEditVideoFetchFrameImageOperation(generator: generator, time: time) { [weak self] image, _ in self?.frameImageCache[indexPath.row] = image let cell = self?.collectionView.cellForItem(at: IndexPath(row: indexPath.row, section: 0)) as? ZLEditVideoFrameImageCell cell?.imageView.image = image if image != nil { self?.requestFailedFrameImageIndex.removeAll { $0 == indexPath.row } } } requestFrameImageQueue.addOperation(operation) } } } class ZLEditVideoFrameImageBorderView: UIView { var validRect: CGRect = .zero { didSet { self.setNeedsDisplay() } } override init(frame: CGRect) { super.init(frame: frame) layer.borderWidth = 2 layer.borderColor = UIColor.clear.cgColor backgroundColor = .clear isOpaque = false } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func draw(_ rect: CGRect) { let context = UIGraphicsGetCurrentContext() context?.setStrokeColor(UIColor.white.cgColor) context?.setLineWidth(4) context?.move(to: CGPoint(x: validRect.minX, y: 0)) context?.addLine(to: CGPoint(x: validRect.minX + validRect.width, y: 0)) context?.move(to: CGPoint(x: validRect.minX, y: rect.height)) context?.addLine(to: CGPoint(x: validRect.minX + validRect.width, y: rect.height)) context?.strokePath() } } class ZLEditVideoFrameImageCell: UICollectionViewCell { lazy var imageView: UIImageView = { let view = UIImageView() view.contentMode = .scaleAspectFill view.clipsToBounds = true return view }() override init(frame: CGRect) { super.init(frame: frame) contentView.addSubview(imageView) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() imageView.frame = bounds } } class ZLEditVideoFetchFrameImageOperation: Operation { private let generator: AVAssetImageGenerator private let time: CMTime let completion: (UIImage?, CMTime) -> Void var pri_isExecuting = false { willSet { self.willChangeValue(forKey: "isExecuting") } didSet { self.didChangeValue(forKey: "isExecuting") } } override var isExecuting: Bool { return pri_isExecuting } var pri_isFinished = false { willSet { self.willChangeValue(forKey: "isFinished") } didSet { self.didChangeValue(forKey: "isFinished") } } override var isFinished: Bool { return pri_isFinished } var pri_isCancelled = false { willSet { self.willChangeValue(forKey: "isCancelled") } didSet { self.didChangeValue(forKey: "isCancelled") } } override var isCancelled: Bool { return pri_isCancelled } init(generator: AVAssetImageGenerator, time: CMTime, completion: @escaping ((UIImage?, CMTime) -> Void)) { self.generator = generator self.time = time self.completion = completion super.init() } override func start() { if isCancelled { fetchFinish() return } pri_isExecuting = true generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: time)]) { _, cgImage, _, result, _ in if result == .succeeded, let cg = cgImage { let image = UIImage(cgImage: cg) ZLMainAsync { self.completion(image, self.time) } self.fetchFinish() } else { self.fetchFinish() } } } override func cancel() { super.cancel() pri_isCancelled = true } private func fetchFinish() { pri_isExecuting = false pri_isFinished = true } }