写在前面
公司项目最近有个小视频功能,上传的视频最长只有15秒,所以需要实现一个视频剪辑的功能。发现微信有这个功能,便准备仿微信的交互写一个,结果遇到不少坑,分享给大家让大家少走弯路。撸起袖子说干就干。
分析需求
我们先看一看微信的界面
微信效果图1.页面下部拖动左边和右边的白色竖条控制剪切视频的开始和结束时间,预览界面跟随拖动位置跳到视频相应帧画面,控制视频长度最长15秒,最短5秒
2.拖动下部图片预览条,视频预览画面跳转到左边白条停留处的帧画面
3.下部操作区域拖动操作时,视频暂停,松手后视频播放,播放内容为两个白条之间的内容,可以循环播放
4.界面的“取消”返回,“确定”后裁剪视频输出
先上一个我做完的效果截图:
仿写效果图我自己设计的控制条跟微信略有不同,微信是最长时间时候左右两个白色竖条离边框都还有一点距离,我这里设计的是两边白条都贴边框,返回按钮和确定裁剪按钮也不同。其实也没差,要说微信那样设计有特殊考虑的话,我只能说我不是交互和视觉设计师😳
实现
1.我这里完整的拖动选择视图是封装的一个view,上面放一个scrollView来展示小的预览图片,再上面放两个image来做视频截取范围的开始和结束指示器。首先需要实现下面缩略图排列以及它的左右滑动,首先需要找到方法获取视频的帧图片。找了一下资料,很多,基本都是同一个方法,所以暂时选取了这个方法。为何说暂时,后面会解释。
#pragma 获取想要时间的帧视频图片
+(UIImage *)getCoverImage:(NSURL *)outMovieURL atTime:(CGFloat)time isKeyImage:(BOOL)isKeyImage{
AVURLAsset *asset = [[AVURLAsset alloc] initWithURL:outMovieURL options:nil];
NSParameterAssert(asset);
AVAssetImageGenerator *assetImageGenerator = [[AVAssetImageGenerator alloc] initWithAsset:asset];
assetImageGenerator.appliesPreferredTrackTransform = YES;
assetImageGenerator.apertureMode = AVAssetImageGeneratorApertureModeEncodedPixels;
__block CGImageRef thumbnailImageRef = NULL;
NSError *thumbnailImageGenerationError = nil;
//tips:下面代码控制时间点的取图是否为关键帧图片,系统为了性能是默认取关键帧图片
CMTime myTime = CMTimeMake(time, 1);
if (!isKeyImage) {
assetImageGenerator.requestedTimeToleranceAfter = kCMTimeZero;
assetImageGenerator.requestedTimeToleranceBefore = kCMTimeZero;
CMTime duration = asset.duration;
myTime = CMTimeMake(time*30,30);
}
thumbnailImageRef = [assetImageGenerator copyCGImageAtTime:myTime actualTime:NULL error:nil];
if (!thumbnailImageRef){
NSLog(@"thumbnailImageGenerationError %@", thumbnailImageGenerationError);
}
UIImage *thumbnailImage = thumbnailImageRef ? [[UIImage alloc]
initWithCGImage:thumbnailImageRef] : nil;
CGImageRelease(thumbnailImageRef);
return thumbnailImage;
}
通常开发者认为时间的呈现格式应该是浮点数据,我们一般使用NSTimeInterval,实际上它是简单的双精度double类型,只是typedef了一下,但是由于浮点型数据计算很容易导致精度的丢失,在一些要求高精度的应用场景显然不适合,于是苹果在Core Media框架中定义了CMTime数据类型作为时间的格式
typedef struct{
CMTimeValue value;
CMTimeScale timescale;
CMTimeFlags flags;
CMTimeEpoch epoch;
} CMTime;
// 显然,CMTime定义是一个C语言的结构体,CMTime是以分数的形式表示时间,value表示分子,timescale表示分母,flags是位掩码,表示时间的指定状态。CMTimeMake(3, 1)结果为3。
我是默认一个完整屏幕宽度为15秒的截取长度,在视频的每秒取一张帧图片作为底部预览小图,起初我是用循环视频时长秒数,每次用上面方法取一张图片,再用UIImageView放置这张图片,最后再计算imageView的位置添加到scrollView上。结果这是一个坑,视频只有二三十秒还好,如果比较长则会创建很多个imageView,内存暴涨,导致卡顿或者直接crash。后来想到了绘图,这样就不会请求内存多次分配空间,从而解决内存暴涨问题。
@interface WZScrollView : UIScrollView
@property (nonatomic, strong) UIImage *image;
@property (nonatomic, assign) CGRect *rect;
-(void)drawImage:(UIImage *)image inRect:(CGRect)rect;
@end
@implementation WZScrollView
-(void)drawRect:(CGRect)rect{
[super drawRect:rect];
[_image drawInRect:rect];
}
-(void)drawImage:(UIImage *)image inRect:(CGRect)rect{
_image = image;
_rect = &rect
[self setNeedsDisplayInRect:rect];
}
结果发现直接画图到scrollView在你拖动scrollView的时候它始终会只显示前面15张图片的效果,o(╯□╰)o!!!测试了一下,滚动是有效果的,但是体验不好啊。后来把上面的继承类从UIScrollView改成了UIView,把图片绘制到view上再加到scrollView上,设置好contentSize,问题解决。
@interface WZScrollView : UIView
接下来就是左右开始和结束的指示图片了,由于图片太小会有可能接收不到点击事件,所以我这里的切图在开始处指示图片的右边和结束指示图片的左边多裁一部分透明范围,这样指示器的面积就比你看到的大了,方便操作。接下来就是它们的拖动操作,最开始我使用的是view的touchesMoved:withEvent:来让图片改变x值从而跟随手指移动。结果发现,手速稍快或者触点稍微偏移就会导致图片位置改变停止,体验和性能都不行。后来改用拖动手势UIPanGestureRecognizer就完美解决了此问题,这里代码多是逻辑处理问题,包括拖动范围何时会让相应图片进行位置改变的响应,上下的白色线条位置和长度改变等等。但这里需要注意三个问题:a.拖动手势的回调方法里面的改变距离和原视图位置的x值会指数相加,每次回调都应该将视图的translation置0。b.需要每次回调都计算开始和结束位置的时间点,让其有实时性。c.拖动结束时需要让播放器循环播放两个时间点间的视频内容。
-(void)panAction:(UIPanGestureRecognizer *)panGR{
//伪代码:根据需求改变开始和结束指示图片的位置
if(panGR.state == UIGestureRecognizerStateChanged){
[panGR setTranslation:CGPointZero inView:self.superview];
}
[self calculateForTimeNodes];//实时计算裁剪时间
if (panGR.state == UIGestureRecognizerStateEnded) {
//伪代码:指示播放器播放相应视频片段代码
}
//计算开始结束时间点
-(void)calculateForTimeNodes{
CGPoint offset = _scrollView.contentOffset;
_startTime =(offset.x+self.startView.frame.origin.x)*15*1.0f/self.bounds.size.width;
_endTime = (offset.x + self.endView.frame.origin.x + KendTimeButtonWidth) * 15 * 1.0f/self.bounds.size.width;
CGFloat imageTime = _startTime;//预览时间点
if (_chooseType == imageTypeEnd) {
imageTime = _endTime;
}
if (self.getTimeRange) {
self.getTimeRange(_startTime,_endTime,imageTime);//控制预览播放界面的当前画面(这里是一个播放页传过来的block的调用)
}
2.拖动scrollView时,默认是展示开始时间点的视频帧画面,在scrollViewDidScroll:方法中调用calculateForTimeNodes方法即可实时更新开始、结束和预览3个时间点参数,这一步的很多逻辑都封装到第一步的一些方法中了,所以这一步比较简单。
3.拖动时暂停播放,松手后播放相应时间范围视频内容,可以循环播放。关于开始和结束指示图片的拖动状态可以用上面提到的panGR.state == UIGestureRecognizerStateEnded来判断,进入判断说明松手了,没有则还在拖动。而scrollView的拖动和停止直接调用它的代理就行了,这里不赘述,不明白可以在demo里面查看。这里遇到个坑是因为前面在视频预览页面拖动的时候需要有当前的视频帧画面用作预览,而开始是getCoverImage: atTime: isKeyImage:这个方法来获取帧图片的,当拖动时就显示图片图层,停止拖动就隐藏图片图层,进而显示下面的视频图层。结果这个方法比较消耗cpu,会导致卡顿情况,还会经常因为cpu过高直接crash。后来发现AVPlayer里面有一个seekToTime: toleranceBefore: toleranceAfter: completionHandler:方法,作用是让视频跳到某个时间点开始播放。我去,这么简单我却饶了好大一个弯,所以大家一定要在使用类的时候要养成多看原类文件的好习惯,可以少跳抗,囧!!!其实AVPlayer还有一个seekToTime:方法,我不使用它的原因是它有一个自己的最小时间单位(貌似是关键帧),用它不会实时改变播放器画面。
视频拖动时:
[_player pause];
[_player seekToTime:CMTimeMake(time*30, 30) toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero completionHandler:^(BOOL finished) {
}];
拖动停止时:
[_player seekToTime:CMTimeMake(_startTime*30, 30) toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero completionHandler:^(BOOL finished) {
[_player play];
}];
4.最后就是对视频进行裁剪了,这里的这个方法不是我写的,是网上找的别人的代码,但是原代码有个小问题,就是输出的视频文件方向改变了。在这里我用了下面3行代码来保证输出视频的方向跟原视频保持一致
AVURLAsset *asset = [AVURLAsset assetWithURL:videoUrl];
AVAssetTrack *assetVideoTrack = [[asset tracksWithMediaType:AVMediaTypeVideo]firstObject];
[compositionVideoTrack setPreferredTransform:assetVideoTrack.preferredTransform];
我这里视频裁剪后的输出视频路径是固定的,所以我封装的方法里面的回调是没有参数的,码友如果需要可以自行改装:
+ (void)addBackgroundMiusicWithVideoUrlStr:(NSURL *)videoUrl audioUrl:(NSURL *)audioUrl andCaptureVideoWithRange:(TimeRange)videoRange completion:(void(^)(void))completionHandle;
大家如果下demo来看的话会发现我在这个方法调用时在回调里面多加了一个保存视频方法到里面,是由于我的项目需求。这个方法会新建一个以项目名称命名的相册,用来存放剪切后的视频,回调会传回一个PHAsset对象(项目需求),这个就是赠送节目了😁。
这里是demo下载地址,如果对你有用的话别忘了star一个😁。
好久没动swift了,本来想写一个swift版练一练,后面再说吧哈哈。。。
网友评论
-(void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
if (self.cutWhenDragEnd) {
self.cutWhenDragEnd();
}
}
原因不是很清楚 这里通过最后结束滚动的时候最后一次从新从裁剪地方播放。互相学习谢谢