1# 视频下载保存及剪辑压缩上传 2 3### 介绍 4 5本示例主要介绍从网上下载视频到相册,以及从相册中选择视频进行剪辑、压缩、以及上传到服务器进行保存。从相册中选择一个视频保存到沙箱中,再使用FFmpeg命令对沙箱中的视频进行压缩、剪辑。最后使用request.agent将剪辑后的视频上传到服务器进行保存。 6 7### 效果图预览 8 9<img src="./video_trimmer.gif" width="300" > 10 11**使用说明** 12 131. 点击视频列表的背景图片,进入到该视频播放界面。 142. 在视频播放界面,点击右上角的图片按钮,进入到视频分享弹窗。 153. 在视频分享弹窗点击“下载”按钮,将视频下载到相册。 164. 在首页点击右上角的“添加”按钮,从相册中选择要剪辑的视频。 175. 视频选择后,点击界面上视频首页的图片,进入到视频剪辑界面。 186. 在视频剪辑界面,选择要剪辑的时间范围后,点击“完成”按钮,进行视频剪辑。 197. 视频剪辑成功后自动返回到上一页,点击右上角的“保存”按钮,将视频保存到服务器。 20 21### 实现步骤 22 23**下载视频:** 24点击视频列表中某个视频,进入视频播放界面。点击播放界面右上角的图片按钮,进行视频分享界面。点击视频分享界面中的下载按钮,将视频下载到相册保存。 251. 定义下载配置参数 request.agent.Config。源码参考[RequestDownload.ets](./entry/src/main/ets/uploadanddownload/RequestDownload.ets)。 26 ```typescript 27 let downloadConfig: request.agent.Config = { 28 action: request.agent.Action.DOWNLOAD, // 下载 29 url: url, // 下载地址 30 method: 'GET', 31 title: 'download', 32 mode: request.agent.Mode.BACKGROUND, // 下载模式(前台或后台) 33 retry: true, // 是否支持重试 34 network: request.agent.Network.ANY, // 支持的网络类型 35 saveas: `./${localPath}`, // 保存路径 36 overwrite: true // 是否覆盖已存在文件 37 } 38 ``` 392. 通过配置创建下载任务,并监听下载进度。当下载完成后通过callback进行回调通知,并删除下载任务。源码参考[RequestDownload.ets](./entry/src/main/ets/uploadanddownload/RequestDownload.ets)。 40 41 ```typescript 42 try { 43 // 创建下载任务 44 this.downloadTask = await request.agent.create(context, downloadConfig); 45 // 监听下载进度 46 this.downloadTask.on('progress', this.progressCallback = (progress: request.agent.Progress) => { 47 logger.info(TAG, `progress = ${progress.processed} ,state = ${progress.state}`); 48 let processed = Number(progress.processed.toString()).valueOf(); 49 let size = progress.sizes[0]; 50 let process: number = Math.floor(processed / size * CommonConstants.PROGRESS_MAX); 51 if (process < CommonConstants.PROGRESS_MAX) { 52 callback(process, false, ''); 53 } 54 }) 55 // 通过‘completed’监听下载完成 56 this.downloadTask.on('completed', this.completedCallback = (progress: request.agent.Progress) => { 57 logger.info(TAG, `download complete, file= ${url}, progress = ${progress.processed}, localPath=${localPath}`); 58 // 通过回调函数传递下载进度和下载保存的地址 59 callback(CommonConstants.PROGRESS_MAX, true, localPath); 60 // 删除任务 61 this.deleteTask(); 62 }) 63 // 启动下载任务 64 await this.downloadTask.start(); 65 } catch (error) { 66 const err: BusinessError = error as BusinessError; 67 logger.error(TAG, `task err, Code is ${err.code}, message is ${err.message}`); 68 callback(CommonConstants.PROGRESS_MAX, false, ''); 69 } 70 } 71 72 ``` 733. 界面收到下载成功的消息后,通过showAssetsCreationDialog拉起权限弹窗,将下载到沙箱中的视频文件通过写数据的方式保存到相册。源码参考[CustomShareDlg.ets](./entry/src/main/ets/customcomponents/CustomShareDlg.ets)。 74 75 ```typescript 76 let phAccessHelper = photoAccessHelper.getPhotoAccessHelper(this.context); 77 try { 78 let photoCreationConfigs: Array<photoAccessHelper.PhotoCreationConfig> = [ 79 { 80 title: 'videoDoanload', 81 fileNameExtension: 'mp4', 82 photoType: photoAccessHelper.PhotoType.VIDEO, 83 subtype: photoAccessHelper.PhotoSubtype.DEFAULT, 84 } 85 ]; 86 // TODO: 知识点: 拉起授予权限的弹窗,获取将视频保存到图库的权限 87 let desFileUris: Array<string> = await phAccessHelper.showAssetsCreationDialog(srcFileUris, photoCreationConfigs); 88 logger.info(TAG, 'saveVideo des is:' + desFileUris[0]); 89 // 转换为uri 90 let uri: string = fileUri.getUriFromPath(srcFileUris[0]); 91 // 打开沙箱路径下视频 92 const file: fs.File = fs.openSync(uri, fs.OpenMode.READ_WRITE); 93 // 将沙箱视频内容写入buffer 94 const videoSize: number = fs.statSync(file.fd).size; 95 let arrayBuffer: ArrayBuffer = new ArrayBuffer(videoSize); 96 let readLength: number = fs.readSync(file.fd, arrayBuffer); 97 let videoBuffer: ArrayBuffer = buffer.from(arrayBuffer, 0, readLength).buffer; 98 try { 99 // 打开图库视频保存的路径 100 let fileInAlbum = fs.openSync(desFileUris[0], fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); 101 // 写入图库 102 await fs.write(fileInAlbum.fd, videoBuffer); 103 // 关闭文件 104 await fs.close(file.fd); 105 await fs.close(fileInAlbum.fd); 106 logger.info(TAG, 'saveVideo success'); 107 // 视频保存成功后,删掉沙箱路径下视频 108 fs.unlinkSync(srcFileUris[0]); 109 promptAction.showToast({ 110 message: $r("app.string.video_trimmer_save_success"), 111 duration: DURATION 112 }); 113 this.controller.close(); 114 } catch (error) { 115 logger.error(TAG, `saveVideo failed, code is: ${error.code}, message is: ${error.message}`); 116 } 117 } catch (err) { 118 logger.error(TAG, `showAssetsCreationDialog failed, errCode is: ${err.code}, message is: ${err.message}`); 119 } 120 ``` 121 122**视频剪辑:** 123点击首页右上角的"添加"按钮,从相册中选择一个视频存入沙箱。通过MP4Parser.getFrameAtTimeRang接口,传入起始时间,获取视频的第一张图片进行展示。点击该图片进入视频剪辑界面,视频剪辑完成后自动回到本界面,点击右上角的保存按钮,将剪辑后的视频上传到配置好的服务器。 124 1251. 通过photoViewPicker从相册中选择一个视频。源码参考[VideoTrimmer.ets](./entry/src/main/ets/pages/VideoTrimmer.ets)。 126 ```typescript 127 // 创建图库选项实例 128 const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions(); 129 // 设置选择的媒体文件类型 130 photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE; 131 // 设置选择媒体文件的最大数目 132 photoSelectOptions.maxSelectNumber = 1; 133 // 创建图库选择器实例 134 const photoViewPicker = new photoAccessHelper.PhotoViewPicker(); 135 // 调用photoViewPicker.select()接口拉起图库界面进行图片选择,图片选择成功后,返回photoSelectResult结果集。 136 photoViewPicker.select(photoSelectOptions).then((photoSelectResult) => { 137 if (photoSelectResult !== null && photoSelectResult !== undefined) { 138 // 将视频保存到沙箱中 139 this.saveFileToSandbox(photoSelectResult.photoUris[0]); 140 } 141 }).catch((err: BusinessError) => { 142 logger.error(TAG, 143 `selectPhotoFromAlbum PhotoViewPicker.select failed :, error code: ${err.code}, message: ${err.message}.`); 144 }) 145 ``` 1462. 将选择的视频写入到沙箱中,写入成功后跳转到视频上传展示界面。源码参考[VideoTrimmer.ets](./entry/src/main/ets/pages/VideoTrimmer.ets)。 147 148 ```typescript 149 /** 150 * 将图库中的视频保存到沙箱中 151 */ 152 async saveFileToSandbox(filePathString: string): Promise<void> { 153 this.localSelectVideoUrl[0] = filePathString; 154 try { 155 // 打开图库中视频的文件 156 let resFile = fs.openSync(filePathString, fs.OpenMode.READ_ONLY); 157 158 const dateStr = (new Date().getTime()).toString() 159 let newPath = getContext().cacheDir + "/" + `${dateStr + resFile.name}`; 160 // 将图库中的视频保存到沙箱中 161 fs.copyFileSync(resFile.fd, newPath); 162 // 新的路径 163 this.localSelectVideoUrl[0] = newPath; 164 logger.info(TAG, 165 `selectPhotoFromAlbum, VideoUpload url:${this.localSelectVideoUrl[0].toString()}`); 166 if (this.localSelectVideoUrl[0] !== undefined && this.localSelectVideoUrl[0] !== '') { 167 // 进入到视频上传展示页面 168 DynamicsRouter.pushUri("videotrimmer/VideoUpload", this.localSelectVideoUrl[0]); 169 } 170 } catch (err) { 171 logger.error(TAG, `selectPhotoFromAlbum.select failed :, error code: ${err.code}, message: ${err.message}.`); 172 } 173 } 174 ``` 1753. 视频上传展示界面初始时,通过MP4Parser.getFrameAtTimeRang接口获取沙箱中视频的首页图片进行展示。源码参考[VideoUpload.ets](./entry/src/main/ets/pages/VideoUpload.ets)。 176 ```typescript 177 let callBack: ICallBack = { 178 // 回调函数 179 callBackResult: (code: number) => { 180 if (code == 0) { 181 let frameCallBack: IFrameCallBack = { 182 callBackResult: async (data: ArrayBuffer, timeUs: number) => { 183 const imageSource: image.ImageSource = image.createImageSource(data); 184 185 let decodingOptions: image.DecodingOptions = { 186 sampleSize: 1, 187 editable: true, 188 desiredSize: { width: CommonConstants.firstImageWidth, height: CommonConstants.firstImageHeight }, 189 desiredPixelFormat: image.PixelMapFormat.RGBA_8888 190 }; 191 await imageSource.createPixelMap(decodingOptions).then(async (px: image.PixelMap) => { 192 this.workItem.firstImage = px; 193 imageSource.release(); 194 }) 195 } 196 } 197 let startTimeUs = CommonConstants.FIRST_IMAGE_START_TIME + ''; 198 let endTimeUs = CommonConstants.FIRST_IMAGE_END_TIME + ''; 199 // TODO: 知识点:传入起始时间,通过MP4Parser的getFrameAtTimeRang接口获取视频的首页图片 200 MP4Parser.getFrameAtTimeRang(startTimeUs, endTimeUs, MP4Parser.OPTION_CLOSEST, frameCallBack); 201 } 202 } 203 } 204 // TODO: 知识点:设置MP4Parser视频源地址及回调函数 205 MP4Parser.setDataSource(this.workItem.videoSrc, callBack); 206 ``` 2074. 视频上传展示界面初始时,配置视频剪辑参数mVideoTrimmerOption。源码参考[VideoUpload.ets](./entry/src/main/ets/pages/VideoUpload.ets)和[视频剪辑参数](./entry/src/main/ets/videotrimmer/VideoTrimmerOption.ets)。 208 209 ```typescript 210 // 视频剪辑参数类 211 export class VideoTrimmerOption { 212 constructor() { 213 this.scaleNum = 100; 214 this.videoMaxTime = 8; 215 this.videoMinTime = 3; 216 this.maxCountRange = 8; 217 this.thumbWidth = 30; 218 this.paddingLineHeight = 10; 219 } 220 221 // 源文件路径 222 @Track srcFilePath: string = ''; 223 // 视频剪辑回调接口 224 @Track listener: VideoTrimListener = { 225 onStartTrim() { 226 }, 227 onFinishTrim(outputFile: string) { 228 }, 229 onCancel() { 230 } 231 }; 232 // 视频帧加载回调接口 233 @Track loadFrameListener: VideoLoadFramesListener = { 234 onStartLoad() { 235 }, 236 onFinishLoad() { 237 } 238 } 239 // 裁剪事件长度 默认值8秒 240 @Track videoMaxTime?: number = 8; 241 // 最小剪辑时间 242 @Track videoMinTime?: number = 3; 243 // seekBar的区域内一共有多少张图片 244 @Track maxCountRange?: number = 8; 245 // 裁剪视频预览长方形条状左右边缘宽度 246 @Track thumbWidth?: number = 30; 247 // 裁剪视频预览长方形条状上下边缘高度 248 @Track paddingLineHeight?: number = 10; 249 // 当加载帧没有完成,默认的占位图 250 @Track framePlaceholder?: PixelMap; 251 // 裁剪视频预览长方形条状区域背景颜色 252 @Track frameBackground?: string; 253 @Track context?: common.UIAbilityContext; 254 // 裁剪时压缩比率,100 为1:1,即不压缩 255 @Track scaleNum?: number = 100; 256 } 257 ``` 258 ```typescript 259 // 视频剪辑参数选项 260 let tempOption = new VideoTrimmerOption(); 261 tempOption.listener = this.initListener; 262 tempOption.loadFrameListener = this.initLoadFrameListener; 263 tempOption.srcFilePath = this.workItem.videoSrc; 264 this.mVideoTrimmerOption = tempOption; 265 ``` 266 2675. 点击视频图片进入到视频剪辑模块,剪辑成功后自动回到本模块。源码参考[VideoUpload.ets](./entry/src/main/ets/pages/VideoUpload.ets)。 268 269 ```typescript 270 @State isTrimmer: boolean = false; // 是否处于剪辑状态 271 272 if (!this.isTrimmer) { 273 this.VideoUpdateInfo(); 274 } else { 275 // TODO: 知识点: 视频剪辑组件VideoTrimmerView。 videoTrimmerOption:视频剪辑相关参数 276 VideoTrimmerView({ videoTrimmerOption: this.mVideoTrimmerOption!! }) 277 } 278 ``` 279 2806. 视频剪辑模块初始化时,根据参数中小图的个数,通过MP4Parser.getFrameAtTimeRang命令循环生成小图列表以便用户选择剪辑时间段。其中在生成第一张小图的时候,根据压缩比率参数,生成视频压缩命令。源码参考[VideoTrimmerView.ets](./entry/src/main/ets/videotrimmer/VideoTrimmerView.ets)。。 281 282 ```typescript 283 initImageList() { 284 // 将视频长度分割为一秒一张图片 285 let videoThumbs = new Array<ThumbContent>() 286 for (let i = 0; i < this.mDuration; i = i + CommonConstants.MS_ONE_SECOND) { 287 let temp = new ThumbContent(); 288 289 if (this.videoTrimmerOption.framePlaceholder) { 290 temp.framePlaceholder = this.videoTrimmerOption.framePlaceholder; 291 } 292 if (this.videoTrimmerOption.frameBackground) { 293 temp.frameBackground = this.videoTrimmerOption.frameBackground; 294 } 295 videoThumbs.push(temp); 296 } 297 298 let firstLoadMax = this.maxCountRange; 299 if (this.mDuration / CommonConstants.MS_ONE_SECOND < this.maxCountRange) { 300 firstLoadMax = Math.ceil(this.mDuration / CommonConstants.MS_ONE_SECOND); 301 } 302 303 let callBack: ICallBack = { 304 callBackResult: (code: number) => { 305 if (code == 0) { 306 let count = 0; 307 308 let frameCallBack: IFrameCallBack = { 309 callBackResult: async (data: ArrayBuffer, timeUs: number) => { 310 const imageSource: image.ImageSource = image.createImageSource(data); 311 // TODO: 知识点: 如果要压缩分辨率,则加载第一张图时,获取视频的压缩分辨率相关参数信息,生成视频压缩命令 312 if (this.videoTrimmerOption!.scaleNum! > 0 && 313 this.videoTrimmerOption!.scaleNum !== CommonConstants.SCALE_NUM && 314 this.imageWidth !== 0) { 315 // 读取图片信息 316 const imageInfo: image.ImageInfo = await imageSource!.getImageInfo(); 317 this.imageWidth = imageInfo.size.width; 318 this.imageHeight = imageInfo.size.height; 319 // 生成视频压缩命令 320 this.scaleCmd = 321 'scale=' + 322 ((this.imageWidth / CommonConstants.SCALE_NUM) * this.videoTrimmerOption!.scaleNum!).toString() + 323 ':' + 324 ((this.imageHeight / CommonConstants.SCALE_NUM) * this.videoTrimmerOption!.scaleNum!).toString() 325 } 326 327 let videoThumbWidth: number = vp2px((this.mSeekBarWidth - 2 * this.thumbWidth) / this.maxCountRange); 328 let videoThumbHeight: number = vp2px(this.mSeekBarHeight); 329 let decodingOptions: image.DecodingOptions = { 330 sampleSize: 1, 331 editable: true, 332 desiredSize: { width: videoThumbWidth, height: videoThumbHeight }, 333 desiredPixelFormat: image.PixelMapFormat.RGBA_8888 334 }; 335 // TODO: 知识点: 在回调函数中循环生成小图,更新到小图列表中 336 imageSource.createPixelMap(decodingOptions).then((px: image.PixelMap) => { 337 let second = timeUs / CommonConstants.US_ONE_SECOND; 338 let framePos = count; 339 if (second == framePos) { 340 logger.info(TAG, 341 'framePos equal second, second=' + second + ' timeUs=' + timeUs + ' length=' + videoThumbs.length); 342 videoThumbs[second].pixelMap = px; 343 this.updateList(videoThumbs); 344 count++; 345 imageSource.release(); 346 } else { 347 logger.info(TAG, 'framePos not equal second, framePos=' + framePos + ' timeUs=' + timeUs); 348 videoThumbs[framePos].pixelMap = px; 349 this.updateList(videoThumbs); 350 count++; 351 imageSource.release(); 352 } 353 if (count == firstLoadMax) { 354 this.videoTrimmerOption.loadFrameListener.onFinishLoad(); 355 } 356 }).catch((err: Error) => { 357 // 部分视频创建图片异常,直接返回 358 console.error("createPixelMap Failed, err = " + err.name + ", message = " + err.message); 359 this.videoTrimmerOption.loadFrameListener.onFinishLoad(); 360 }) 361 } 362 } 363 // TODO: 知识点: 根据开始、结束时间,通过MP4Parser.getFrameAtTimeRang命令循环生成小图 364 let startTime = 0 * CommonConstants.US_ONE_SECOND + ''; 365 let endTime = (firstLoadMax - 1) * CommonConstants.US_ONE_SECOND + ''; 366 // 通过开始、结束时间,回调函数,获取视频小图 367 MP4Parser.getFrameAtTimeRang(startTime, endTime, MP4Parser.OPTION_CLOSEST, frameCallBack); 368 } else { 369 logger.info(TAG, 'setDataSource fail, error code =' + code.toString()); 370 } 371 } 372 } 373 this.videoTrimmerOption.loadFrameListener.onStartLoad(); 374 // 设置视频源和回调函数 375 MP4Parser.setDataSource(this.videoTrimmerOption.srcFilePath, callBack); 376 logger.info(TAG, "initList list size=" + videoThumbs.length); 377 this.updateList(videoThumbs); 378 } 379 ``` 380 3817. 在视频剪辑界面,拉动左右进度条,根据拉动后的位置,计算选择要剪辑的时间范围,源码参考[RangeSeekBarView.ets](./entry/src/main/ets/videotrimmer/RangeSeekBarView.ets)。 382获取拉动位置: 383 ```typescript 384 .onActionUpdate((event?: GestureEvent) => { 385 let touchXNew: number = this.clearUndefined(event?.offsetX); 386 let deltax: number = touchXNew - this.touchXOld; 387 // 左边滑块移动 388 if (this.touchStatus == this.touch_left_thumb) { 389 this.leftThumbUpdate(deltax); 390 } else if (this.touchStatus == this.touch_right_thumb) { 391 // 右边滑块移动 392 this.rightThumbUpdate(deltax); 393 } else if (this.touchStatus == this.touch_hor_scroll) { 394 this.scrollUpdate(deltax); 395 } 396 this.touchDeltaX = deltax; 397 this.touchXOld = this.clearUndefined(event?.offsetX); 398 }) 399 .onActionEnd((event?: GestureEvent) => { 400 this.touchStatus = this.touch_hor_scroll; 401 this.onRangeStopScrollChanged(); 402 }) 403 ``` 404 根据拉动位置计算选择的时间段: 405 ```typescript 406 // 选取时间变动事件 407 onRangeValueChanged() { 408 let x0: number = this.scroller.currentOffset().xOffset; 409 let start: number = x0 + this.leftThumbRect[2] - this.leftThumbWidth; 410 let end: number = start + this.transparentWidth; 411 412 let startTime: number = start * CommonConstants.US_ONE_SECOND / this.msPxAvg; 413 this.leftText = this.showThumbText(startTime); 414 let endTime: number = end * CommonConstants.US_ONE_SECOND / this.msPxAvg; 415 this.rightText = this.showThumbText(endTime); 416 417 if (this.mRangSeekBarListener) { 418 this.mRangSeekBarListener.onRangeSeekBarValuesChanged(startTime, endTime); 419 } 420 } 421 ``` 422 4238. 点击完成按钮,使用MP4Parser.ffmpegCmd接口,根据选择的起始时间和视频压缩命令,对视频进行压缩和剪辑,并保存到本地沙箱。源码参考[VideoTrimmerView.ets](./entry/src/main/ets/videotrimmer/VideoTrimmerView.ets)。 424 425 ```typescript 426 // 最后一帧保护 427 let sTime1 = 0; 428 let eTime1 = 0; 429 let lastFrameTime = 430 Math.floor(this.mDuration / CommonConstants.MS_ONE_SECOND) * CommonConstants.MS_ONE_SECOND; 431 if (this.endTruncationTime > lastFrameTime) { 432 eTime1 = lastFrameTime; 433 sTime1 = eTime1 - (this.endTruncationTime - this.startTruncationTime); 434 eTime1 += CommonConstants.MS_ONE_SECOND; // 补偿1s 435 } else { 436 sTime1 = this.startTruncationTime; 437 eTime1 = this.endTruncationTime; 438 } 439 // 选取的裁剪时间段 440 let sTime = TimeUtils.msToHHMMSS(sTime1); 441 let eTime = TimeUtils.msToHHMMSS(eTime1); 442 // 原视频 443 let srcFilePath = this.videoTrimmerOption.srcFilePath; 444 // 保留原来的文件名 445 let fileName: string | undefined = srcFilePath!.split('/').pop()!.split('.')[0]; 446 // 组装剪辑后的文件路径(以 原来的文件名+当前时间 命名) 447 let outFilePath: string = 448 getContext(this).cacheDir + '/' + fileName + '_' + TimeUtils.format(new Date()) + '.mp4'; 449 // 剪辑回调函数 450 let callback: ICallBack = { 451 callBackResult: (code: number) => { 452 if (code == 0) { 453 if (this.videoTrimmerOption) { 454 // 通知上层调用 455 this.videoTrimmerOption.listener.onFinishTrim(outFilePath); 456 this.isTrimming = false; 457 } 458 } 459 } 460 } 461 // TODO: // 知识点: 根据开始、结束时间,视频源以及目标文件地址对视频进行剪辑 462 this.videoClip(sTime, eTime, srcFilePath, outFilePath, this.scaleCmd, callback); 463 ``` 464 465 ```typescript 466 // TODO: 知识点: 视频剪辑。 scaleCmd为视频压缩命令 467 videoClip(sTime: string, eTime: string, srcFilePath: string, outFilePath: string, scaleCmd: string, 468 callback: ICallBack) { 469 let ffmpegCmd: string = ''; 470 if (scaleCmd !== '') { 471 ffmpegCmd = 472 'ffmpeg -y -i ' + srcFilePath + ' -vf ' + scaleCmd + ' -c:v mpeg4 -c:a aac -ss ' + sTime + ' -to ' + eTime + 473 ' ' + outFilePath; 474 } else { 475 ffmpegCmd = 'ffmpeg -y -i ' + srcFilePath + ' -c:v mpeg4 -c:a aac -ss ' + sTime + ' -to ' + eTime + 476 ' ' + outFilePath; 477 } 478 logger.info(TAG, 'videoClip cmd: ' + ffmpegCmd); 479 MP4Parser.ffmpegCmd(ffmpegCmd, callback); 480 } 481 ``` 482 483**视频上传:** 484视频剪辑成功后自动跳转到视频上传界面。点击右上角的“保存”按钮,如果检测到没有配置服务器,则弹窗提示设置服务器地址,设置后重新点击右上角“保存”按钮,上传视频到服务器进行保存。 4851. 参照[environment](./environment/README.md)配置服务器地址,视频剪辑完成后点击“保存”,视频将上传到该服务器地址。 486 4872. 点击保存时,检测是否配置了服务器。源码参考[VideoUpload.ets](./entry/src/main/ets/pages/VideoUpload.ets)。 488 ```typescript 489 // 获取服务器地址 490 this.serverUrl = await urlUtils.getUrl(getContext(this) as common.UIAbilityContext); 491 if (this.serverUrl === undefined || this.serverUrl === '') { 492 // 打开自定义对话框,配置服务器地址 493 await this.customSetServerDlg.open(); 494 } 495 this.serverUrl = await urlUtils.getUrl(getContext(this) as common.UIAbilityContext); 496 logger.info(TAG, 'serverUrl is = ' + this.serverUrl); 497 if (this.serverUrl !== undefined && this.serverUrl.length > 1) { 498 return true; 499 } else { 500 return false; 501 } 502 ``` 503 5043. 定义上传配置参数 request.agent.Config。源码参考[RequestUpload.ets](./entry/src/main/ets/uploadanddownload/RequestUpload.ets)。 505 ```typescript 506 private config: request.agent.Config = { 507 action: request.agent.Action.UPLOAD, // 上传 508 headers: HEADER, 509 url: '', // 上传服务器地址 510 mode: request.agent.Mode.FOREGROUND, // 前台方式 511 method: 'POST', 512 title: 'upload', 513 network: request.agent.Network.ANY, // 网络类型 514 data: [], 515 token: UPLOAD_TOKEN 516 } 517 ``` 518 5194. 根据配置创建上传任务,并监听上传进度。当上传完成后通过callback进行回调通知,并删除上传任务。源码参考[RequestUpload.ets](./entry/src/main/ets/uploadanddownload/RequestUpload.ets)。 520 ```typescript 521 private uploadTask: request.agent.Task | undefined = undefined; // 上传任务 522 523 // 获取本地上传文件 524 this.config.data = await this.getFilesAndData(context.cacheDir, fileUris); 525 // TODO : 知识点 将视频上传到服务器地址 526 // 获取服务器地址 527 this.config.url = await urlUtils.getUrl(context); 528 // 前台模式 529 this.config.mode = request.agent.Mode.FOREGROUND; 530 try { 531 // 创建上传任务 532 this.uploadTask = await request.agent.create(context, this.config); 533 logger.info(TAG, `create uploadTask success, TaskID= ${this.uploadTask.tid}`); 534 // 监听上传进度 535 this.uploadTask.on('progress', this.progressCallback = (progress: request.agent.Progress) => { 536 logger.info(TAG, `progress, progress = ${progress.processed} ${progress.state}`); 537 let processed = Number(progress.processed.toString()).valueOf(); 538 let size = progress.sizes[0]; 539 let process: number = Math.floor(processed / size * CommonConstants.PROGRESS_MAX); 540 if (process < CommonConstants.PROGRESS_MAX) { 541 // 进度通知 542 callback(process, false); 543 } 544 }); 545 // 下载完成事件 546 this.uploadTask.on('completed', this.completedCallback = (progress: request.agent.Progress) => { 547 logger.info(TAG, `complete, progress = ${progress.processed}, state= ${progress.state}`); 548 // 通知下载完成 549 callback(CommonConstants.PROGRESS_MAX, true); 550 // 删除任务 551 this.deleteTask(); 552 }); 553 } 554 ``` 555 5565. 视频上传成功后,删除本地裁剪的视频,然后返回视频列表首页。源码参考[VideoUpload.ets](./entry/src/main/ets/pages/VideoUpload.ets)。 557 ```typescript 558 // 上传成功后删除裁剪的视频 559 fs.unlinkSync(this.workItem.videoSrc); 560 // 视频地址替换为服务器上的视频 561 this.workItem.videoSrc = this.serverUrl + this.workItem.videoSrc.split('/').pop(); 562 563 // 保存首页背景图 564 async saveFirstImage() { 565 const url = await savePixelMap(getContext(this), this.workItem.firstImage as PixelMap, getTimeStr()); 566 this.workItem.firstImage = fileUri.getUriFromPath(url); 567 this.workItem.date = TimeUtils.getCurrentTime(); 568 // 通知视频列表首页更新上传的数据 569 AppStorage.setOrCreate('addWorkItem', this.workItem); 570 await this.customSetServerDlg.close(); 571 DynamicsRouter.popAppRouter(); 572 } 573 ``` 574 5756. 视频列表首页监听“addWorkItem”数据,更新上传的视频数据。源码参考[VideoTrimmer.ets](./entry/src/main/ets/pages/VideoTrimmer.ets)。 576 ```typescript 577 @StorageLink('addWorkItem') @Watch('getUploadWorkItem') addWorkItem: WorkItem = 578 new WorkItem('', '', '', '', '', true); // 上传到服务器的视频信息 579 580 // 获取并添加新发表的视频信息 581 getUploadWorkItem(): void { 582 if (this.addWorkItem.videoSrc !== undefined && this.addWorkItem.videoSrc !== '') { 583 this.workList.addData(0, this.addWorkItem); 584 AppStorage.setOrCreate('addWorkItem', new WorkItem('', '', '', '', '', false)); 585 } 586 } 587 ``` 588 589### 高性能知识点 590 591本示例使用了[LazyForEach](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-arkui/arkui-ts/ts-rendering-control-lazyforeach.md) 进行数据懒加载优化,以降低内存占用和渲染开销。 592 593### 工程结构&模块类型 594 595 ``` 596videotrimmer // har类型 597|---constants // 常量 598| |---Constants.ets // 通用常量 599| |---DoanloadConstants.ets // 下载常量 600|---customcomponents // 自定义组件 601| |---CustomLoadingProgressDlg.ets // 加载进度 602| |---CustomSetServerDlg.ets // 设置上传下载服务器 603| |---CustomShareDlg.ets // 视频分享 604| |---CustomTabBar.ets // 首页底部自定义组件 605|---model 606| |---ShareInfo.ets // 分享信息数据 607| |---TabBarModel.ets // Tab组件信息 608| |---WorkListDataSource.ets // IDataSource处理数据监听的基本实现 609| |---WorkItemModel.ets // 单条视频信息模型 610|---pages 611| |---VideoDetail.ets // 视图层-视频播放界面 612| |---VideoTrimmer.ets // 视图层-应用主页面 613| |---VideoUpload.ets // 视图层-视频保存上传界面 614|---uploadanddownload // 上传下载 615| |---RequestDownload.ets // 下载类 616| |---VideoUpload.ets // 上传类 617|---utils // 通用工具 618| |---FileUtil.ets // 文件处理 619| |---Logger.ets // 日志 620| |---TimeUtils.ets // 时间处理 621| |---UrlUtils.ets // 服务器地址处理 622|---videoplaycomponents // 视频播放组件 623| |---VideoPlayController.ets // 视频控制器 624| |---VideoPlayListener.ets // 视频播放侦听回调 625| |---XComponentVideo.ets // 视频播放组件 626|---videotrimmer // 视频裁剪组件 627| |---RangeSeekBarView.ets // 视频剪辑时间长度选择组件 628| |---RangSeekBarListener.ets // 视频剪辑长度侦听接口 629| |---RangSeekBarOption.ets // 视频剪辑长度选项 630| |---VideoLoadFramesListener.ets // 视频帧加载回调接口 631| |---VideoThumbListView.ets // 视频帧图片列表预览组件 632| |---VideoThumbOption.ets // 视频帧图片选项 633| |---VideoTrimListener.ets // 视频剪辑回调接口 634| |---VideoTrimmerOption.ets // 视频剪辑选项 635| |---VideoTrimmerView.ets // 视频裁剪组件 636 ``` 637 638### 模块依赖 639 640 依赖[mp4parser](https://ohpm.openharmony.cn/#/cn/detail/@ohos%2Fmp4parser)来使用FFmpeg命令。 641 642### 参考资料 643 644 [video_trimmer](https://ohpm.openharmony.cn/#/cn/detail/@ohos%2Fvideotrimmer)源码 645 [XComponent组件](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-arkui/arkui-ts/ts-basic-components-xcomponent.md) 646 [mp4parser](https://ohpm.openharmony.cn/#/cn/detail/@ohos%2Fmp4parser) 647 [上传下载](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-basic-services-kit/js-apis-request.md) 648 649### 相关权限 650 651| 权限名 | 权限说明 | 级别 | 652|----------------------------------------|----------------------------| ------------ | 653| ohos.permission.INTERNET | 允许访问网络。 | normal | 654| ohos.permission.READ_MEDIA | 允许应用读取用户外部存储中的媒体文件信息。 | normal | 655| ohos.permission.WRITE_MEDIA | 允许应用读写用户外部存储中的媒体文件信息。 | normal | 656| ohos.permission.CAMERA | 允许应用使用相机。 | normal | 657 658 659### 依赖 660 661- 不涉及 662 663### 约束与限制 664 6651.本示例仅支持标准系统上运行,支持设备:手机。 666 6672.本示例为Stage模型,支持API12版本SDK,SDK版本号(API Version 12 Release)。 668 6693.本示例需要使用DevEco Studio版本号(DevEco Studio 5.0.0 Release)及以上版本才可编译运行。 670 671 672### 下载 673如需单独下载本工程,执行如下命令: 674 675``` 676git init 677git config core.sparsecheckout true 678echo code/BasicFeature/Media/videotrimmer/ > .git/info/sparse-checkout 679git remote add origin https://gitee.com/openharmony/applications_app_samples.git 680git pull origin master 681```