Commit 552c2145 by tanghuan

上传文件:1.针对视频文件增加转码和压缩的进度反馈;2.增加上传进度反馈。

1 parent 596a373b
......@@ -408,6 +408,67 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver {
_controller.runJavaScript(script);
}
/// 相应 uploadStart 指令
/// [unique] 消息唯一标识
/// [uploadId] 文件上传ID标识
///
void sendUploadStartResponse(String unique, String uploadId) {
var resp = {
'unique': unique,
'cmd': 'uploadStart',
'data': {
'uploadId': uploadId,
'status': 1,
'percent': 0,
'totalPart': 0,
'sendedPart': 0,
'totalByte': 0,
'sendedByte': 0,
},
'errMsg': '',
};
_sendResponse(resp);
}
/// 发送上传进度到 WebView
/// [unique] 消息唯一标识
/// [uploadId] 文件上传ID标识
/// [totalPart] 总分片数
/// [sendedPart] 已上传分片数
/// [totalByte] 总字节数
/// [sendedByte] 已上传字节数
void sendUploadProgress(
String unique, String uploadId, int status, int percent, int totalPart, int sendedPart, int totalByte, int sendedByte) {
var resp = {
'unique': unique,
'cmd': 'uploadProgress',
'data': {
'uploadId': uploadId,
'status': status,
'percent': percent,
'totalPart': totalPart,
'sendedPart': sendedPart,
'totalByte': totalByte,
'sendedByte': sendedByte,
},
'errMsg': '',
};
_sendResponse(resp);
}
void sendUploadEnd(String unique, String uploadId, String url, {String errMsg = ''}) {
var resp = {
'unique': '',
'cmd': 'uploadEnd',
'data': {
'uploadId': uploadId,
'url': url,
},
'errMsg': errMsg
};
_sendResponse(resp);
}
void finishLoading() {
emit(state.copyWith(loaded: true));
}
......
......@@ -24,7 +24,7 @@ class Constant {
/// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
///
/// obs文件分片上传的分片大小:5M
static const int obsUploadChunkSize = 1024 * 1024 * 5;
static const int obsUploadChunkSize = 1024 * 1024 * 1;
/// obs文件上传的逻辑前缀
static const String obsLogicPrefix = EnvConfig.env == 'dev' ? 'd2/pridel/user/' : 'p2/unpridel/user/';
......
......@@ -30,6 +30,7 @@ import 'package:appframe/data/repositories/message/share_to_wx_handler.dart';
import 'package:appframe/data/repositories/message/storage_handler.dart';
import 'package:appframe/data/repositories/message/title_bar_handler.dart';
import 'package:appframe/data/repositories/message/upload_file.dart';
import 'package:appframe/data/repositories/message/upload_start_handler.dart';
import 'package:appframe/data/repositories/message/vibrate_short_handler.dart';
import 'package:appframe/data/repositories/message/video_info_handler.dart';
import 'package:appframe/data/repositories/message/wifi_info_handler.dart';
......@@ -179,6 +180,7 @@ Future<void> setupLocator() async {
/// 上传文件
getIt.registerLazySingleton<MessageHandler>(() => UploadFileHandler(), instanceName: 'uploadFile');
getIt.registerLazySingleton<MessageHandler>(() => UploadStartHandler(), instanceName: 'uploadStart');
/// 下载文件
getIt.registerLazySingleton<MessageHandler>(() => DownloadFileHandler(), instanceName: 'downloadFile');
......
import 'dart:convert';
import 'dart:io';
import 'package:appframe/bloc/web_cubit.dart';
import 'package:appframe/config/env_config.dart';
import 'package:appframe/config/constant.dart';
import 'package:appframe/config/locator.dart';
import 'package:appframe/services/api_service.dart';
import 'package:appframe/services/dispatcher.dart';
import 'package:appframe/utils/file_type_util.dart';
import 'package:appframe/utils/image_util.dart';
import 'package:appframe/utils/video_util.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
class UploadStartHandler extends MessageHandler {
late WebCubit? _webCubit;
/// 指令 unique
late String _cmdUnique;
/// 文件上传ID标识
late String _cmdUploadId;
late int _cmdTotalChunks;
late int _cmdUploadedChunks;
late int _cmdTotalByte;
late int _cmdSentByte;
@override
void setCubit(WebCubit cubit) {
_webCubit = cubit;
}
/// 设置指令 unique
void setCmdUnique(String unique) {
_cmdUnique = unique;
}
void _unfollowCubit() {
_webCubit = null;
}
@override
Future<dynamic> handleMessage(params) async {
try {
if (params is! Map<String, dynamic>) {
throw Exception('参数错误');
}
final String? tempFilePath = params['tempFilePath'] as String?;
if (tempFilePath == null || tempFilePath.isEmpty) {
throw Exception('参数错误');
}
final String? busi = params['busi'] as String?;
if (busi == null || busi.isEmpty) {
throw Exception('参数错误');
}
final String? subBusi = params['subBusi'] as String?;
if (subBusi == null || subBusi.isEmpty) {
throw Exception('参数错误');
}
// 开始处理前,先生成唯一 cmdUploadId
_cmdUploadId = const Uuid().v4();
final startTime = DateTime.now();
final result = await _handle(tempFilePath, busi, subBusi);
final endTime = DateTime.now();
debugPrint('====================>上传耗时:${endTime.millisecondsSinceEpoch - startTime.millisecondsSinceEpoch} 毫秒');
// 处理结果 result, 发送 uploadEnd 指令
final String url = result['url'] as String;
_webCubit?.sendUploadEnd(_cmdUnique, _cmdUploadId, url);
// 返回 null,让 MessageDispatcher 不处理返回指令
return null;
} catch (e) {
debugPrint('====================>上传失败:$e');
// 确保发送了 uploadEnd 指令
if (e is Exception && e.toString() != Exception('参数错误').toString()) {
_webCubit?.sendUploadEnd(_cmdUnique, _cmdUploadId, '', errMsg: e.toString());
}
} finally {
_unfollowCubit();
}
}
Future<Map<String, dynamic>> _handle(String filePath, String busi, String subBusi) async {
///
/// 1 判断
///
if (filePath.startsWith(Constant.localFileUrl)) {
filePath = filePath.replaceFirst(Constant.localFileUrl, '');
}
if (filePath.startsWith(Constant.localServerTemp)) {
filePath = filePath.replaceFirst(Constant.localServerTemp, '');
}
//判断文件
File file = File(filePath);
if (!file.existsSync()) {
throw Exception('文件不存在');
}
var fileSize = file.lengthSync();
debugPrint('原始文件大小:$fileSize 字节');
/// 发送 uploadStart 响应指令
_webCubit?.sendUploadStartResponse(_cmdUnique, _cmdUploadId);
///
/// 视频文件上传之前进行压缩
/// 非 mp4 格式的视频文件需先转码
///
String? mimeType = await FileTypeUtil.getMimeType(file);
if (mimeType?.toLowerCase().startsWith('video/') ?? false) {
final inputPath = filePath;
final tempDir = await getTemporaryDirectory();
final outputPath = '${tempDir.path}/${Uuid().v4()}.mp4';
bool success = false;
var startTime = DateTime.now();
if (mimeType != 'video/mp4') {
success = await VideoUtil.convertToMp4(
inputPath,
outputPath,
onProgress: (progress) {
// progress 范围 0 ~ 100
debugPrint('转码进度: $progress%');
/// 发送转码进度
_webCubit?.sendUploadProgress(_cmdUnique, _cmdUploadId, 1, progress, 0, 0, 0, 0);
},
);
} else {
success = await VideoUtil.compressVideo(
inputPath,
outputPath,
'low',
onProgress: (progress) {
// progress 范围 0 ~ 100
debugPrint('压缩进度: $progress%');
/// 发送压缩进度
_webCubit?.sendUploadProgress(_cmdUnique, _cmdUploadId, 1, progress, 0, 0, 0, 0);
},
);
}
var endTime = DateTime.now();
debugPrint('====================>压缩耗时:${endTime.millisecondsSinceEpoch - startTime.millisecondsSinceEpoch} 毫秒');
if (success) {
file = File(outputPath);
fileSize = file.lengthSync();
debugPrint('====================>视频压缩后大小:$fileSize 字节');
}
} else if (mimeType?.toLowerCase().startsWith('image/') ?? false) {
// 对于图片文件,进行压缩
final inputPath = filePath;
final tempDir = await getTemporaryDirectory();
final outputPath = '${tempDir.path}/${Uuid().v4()}.jpg';
var startTime = DateTime.now();
final success = await ImageUtil.compressImage(inputPath, outputPath, maxWidth: 1920, quality: 18);
var endTime = DateTime.now();
debugPrint('====================>图片压缩耗时:${endTime.millisecondsSinceEpoch - startTime.millisecondsSinceEpoch} 毫秒');
if (success) {
file = File(outputPath);
fileSize = file.lengthSync();
debugPrint('====================>图片压缩后大小:$fileSize 字节');
}
}
// 限制压缩后仍然大于300M的文件上传
if (fileSize > 1024 * 1024 * 300) {
throw Exception('上传的文件过大');
}
/// 2
/// bucket 存储桶名称 : bxe-files | bxe-pics | bxe-videos
///
String bucket;
if (mimeType?.startsWith('image/') ?? false) {
bucket = 'bxe-pics';
} else if (mimeType?.startsWith('video/') ?? false) {
bucket = 'bxe-videos';
} else {
bucket = 'bxe-files';
}
/// 3
/// objectKey
var uuid = Uuid();
String logicPrefix = _getLoginPrefix(busi, subBusi);
String objectKey = '$logicPrefix/${uuid.v4()}${path.extension(file.path)}';
///
/// 4 计算分片
///
final chunkSize = Constant.obsUploadChunkSize;
final totalChunks = (fileSize / chunkSize).ceil();
debugPrint('上传文件大小:$fileSize 字节');
debugPrint('分片数量:$totalChunks');
_cmdTotalChunks = totalChunks;
_cmdUploadedChunks = 0;
_cmdTotalByte = fileSize;
_cmdSentByte = 0;
///
/// 5 sig
///
var startTime1 = DateTime.now();
debugPrint('====================>签名开始 $startTime1');
final bxeApiService = ApiService(baseUrl: Constant.iotAppBaseUrl);
late String uploadId;
var signUrls = [];
for (int i = 0; i < totalChunks; i++) {
if (i == 0) {
final initResult = await _init(bxeApiService, objectKey, bucket);
uploadId = initResult['upload_id'] as String;
var signUrl = initResult['signed_url'] as String;
signUrls.add(signUrl);
} else {
final nextResult = await _next(bxeApiService, objectKey, bucket, uploadId, i + 1);
var signUrl = nextResult['signed_url'] as String;
signUrls.add(signUrl);
}
}
var endTime1 = DateTime.now();
debugPrint('====================>签名耗时:${endTime1.millisecondsSinceEpoch - startTime1.millisecondsSinceEpoch} 毫秒');
///
/// 6 上传(带进度反馈)
///
final dio = Dio()
..options = BaseOptions(
baseUrl: '',
connectTimeout: Duration(milliseconds: 30000),
receiveTimeout: Duration(milliseconds: 30000),
headers: {'Content-Type': '', 'Accept': ''},
);
final randomAccessFile = await file.open();
Map<int, String> tagsMap = {};
// 创建分片上传任务列表
final uploadTasks = <Future<Map<String, dynamic>>>[];
for (int i = 0; i < totalChunks; i++) {
final chunkSize = Constant.obsUploadChunkSize;
final start = i * chunkSize;
final actualChunkSize = (i + 1) * chunkSize > fileSize ? fileSize - start : chunkSize;
final chunk = Uint8List(actualChunkSize);
randomAccessFile.setPositionSync(start);
await randomAccessFile.readInto(chunk, 0, actualChunkSize);
uploadTasks.add(_uploadChunkWithProgress(
dio,
signUrls[i],
i,
chunk,
onChunkComplete: () {
_cmdUploadedChunks++;
_cmdSentByte = _cmdSentByte + actualChunkSize;
/// 发送 uploadProgress 指令,传递上传进度
_webCubit?.sendUploadProgress(
_cmdUnique,
_cmdUploadId,
2,
((_cmdUploadedChunks / _cmdTotalChunks) * 100).floor(),
_cmdTotalChunks,
_cmdUploadedChunks,
_cmdTotalByte,
_cmdSentByte,
);
},
));
}
var resultList = await Future.wait(uploadTasks);
for (var result in resultList) {
if (result is Map<String, dynamic>) {
tagsMap[result['idx'] as int] = result['etag'] as String;
}
}
await randomAccessFile.close();
///
/// 7 合并
///
var startTime2 = DateTime.now();
String location = await _merge(bxeApiService, objectKey, bucket, uploadId, tagsMap);
var endTime2 = DateTime.now();
debugPrint('====================>合并签名耗时:${endTime2.millisecondsSinceEpoch - startTime2.millisecondsSinceEpoch} 毫秒');
///
/// 8 针对视频生成封面
///
if (mimeType?.startsWith('video/') ?? false) {
await _genHwVideoCover(dio, objectKey);
}
dio.close(force: true);
bxeApiService.close();
return {'url': _addPreUrl(location)};
}
static const _signatureNewUrl = '/api/v1/obs/multipart/signaturenew';
static const _signatureNextUrl = '/api/v1/obs/multipart/signaturenext';
static const _completeUrl = '/api/v1/obs/multipart/complete';
/// 初始化,请求后端获取签名信息和上传任务ID
Future<Map<String, dynamic>> _init(ApiService bxeApiService, String objectKey, String bucket) async {
var endpoint = '$_signatureNewUrl?objectKey=$objectKey&bucket=$bucket';
final resp = await bxeApiService.get(endpoint);
return resp.data;
}
/// 每次上传前,请求后端获取签名信息
Future<Map<String, dynamic>> _next(
ApiService bxeApiService,
String objectKey,
String bucket,
String uploadId,
int partNum,
) async {
var endpoint = '$_signatureNextUrl?objectKey=$objectKey&bucket=$bucket&uploadId=$uploadId&partNum=$partNum';
final resp = await bxeApiService.get(endpoint);
return resp.data;
}
/// 上传段(带进度回调)
Future<Map<String, dynamic>> _uploadChunkWithProgress(
Dio dio,
String signUrl,
int chunkIndex,
Uint8List chunk, {
VoidCallback? onChunkComplete,
int maxRetries = 3,
}) async {
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
var starTime = DateTime.now();
final resp = await _uploadChunk(dio, signUrl, chunk, chunkIndex);
var endTime = DateTime.now();
if (resp.statusCode == 200) {
debugPrint(
'====================> 分片$chunkIndex${attempt + 1}次, $endTime 上传耗时:${endTime.millisecondsSinceEpoch - starTime.millisecondsSinceEpoch} 毫秒');
final etags = resp.headers['etag'] as List<String>;
// 分片上传成功,触发回调
onChunkComplete?.call();
return {'idx': chunkIndex + 1, 'etag': etags[0]};
} else {
throw Exception('Chunk $chunkIndex upload failed: ${resp.statusCode}');
}
} catch (e) {
debugPrint('====================> 分片$chunkIndex${attempt + 1}次, 上传失败:${e.toString()}');
if (attempt == maxRetries) {
throw Exception('Chunk $chunkIndex upload failed after $maxRetries attempts: $e');
}
// 等待后重试
await Future.delayed(Duration(seconds: 2 * (attempt + 1)));
}
}
throw Exception('上传失败');
}
/// 上传段,按照最大重试次数进行上传重试
Future<Map<String, dynamic>> _uploadChunkWithRetry(
Dio dio,
String signUrl,
int chunkIndex,
Uint8List chunk, {
int maxRetries = 3,
}) async {
//print('====================> 分片$chunkIndex , 开始上传 ${DateTime.now()}');
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
var starTime = DateTime.now();
final resp = await _uploadChunk(dio, signUrl, chunk, chunkIndex);
var endTime = DateTime.now();
if (resp.statusCode == 200) {
debugPrint(
'====================> 分片$chunkIndex${attempt + 1}次, $endTime 上传耗时:${endTime.millisecondsSinceEpoch - starTime.millisecondsSinceEpoch} 毫秒');
final etags = resp.headers['etag'] as List<String>;
return Future.value({'idx': chunkIndex + 1, 'etag': etags[0]}); // 上传成功
} else {
throw Exception('Chunk $chunkIndex upload failed: ${resp.statusCode}');
}
} catch (e) {
debugPrint('====================> 分片$chunkIndex${attempt + 1}次, 上传失败:${e.toString()}');
if (attempt == maxRetries) {
throw Exception('Chunk $chunkIndex upload failed after $maxRetries attempts: $e');
}
// 等待后重试
await Future.delayed(Duration(seconds: 2 * attempt));
}
}
throw Exception('上传失败');
}
/// 上传段
Future<Response> _uploadChunk(Dio dio, String signUrl, Uint8List chunk, int chunkIndex) async {
var url = signUrl.replaceFirst('AWSAccessKeyId=', 'AccessKeyId=').replaceFirst(':443', '');
try {
// Response response = await _put(url, chunk);
debugPrint('====================> 分片$chunkIndex , 开始上传 ${DateTime.now()}');
final response = await dio.put(
url,
// data: Stream.fromIterable(chunk.map((e) => [e])),
// data: Stream.fromIterable([chunk]),
data: chunk,
);
debugPrint('====================> 分片$chunkIndex , 上传成功 ${DateTime.now()}');
return response;
} catch (e) {
throw Exception('Chunk upload failed: $e');
}
}
/// 请求合并文件
Future<String> _merge(
ApiService bxeApiService,
String objectKey,
String bucket,
String uploadId,
Map<int, String> tagsMap,
) async {
final parts = [];
for (int i = 1; i <= tagsMap.length; i++) {
parts.add({'partNumber': i, 'etag': tagsMap[i]});
}
final response = await bxeApiService.post(_completeUrl, {
'objectKey': objectKey,
'bucket': bucket,
'uploadId': uploadId,
'parts': parts,
});
if (response.statusCode != 200) {
throw Exception('合并文件失败');
}
return response.data["location"];
}
String _getLoginPrefix(String busi, String subBusi) {
var now = DateTime.now();
var year = now.year;
var month = now.month;
var day = now.day;
String userCode = getIt.get<SharedPreferences>().getString('auth_userCode') ?? 'na';
String classCode = getIt.get<SharedPreferences>().getString('auth_classCode') ?? 'nac';
String obsLogicPrefix = "d2";
if (EnvConfig.env == 'pro') {
obsLogicPrefix = "p2";
}
String busiCode = '${busi}_$subBusi';
// 属于该特定业务范围的素材,都被看作日后运维可以优先删除的文件,规则:http://wiki.zbuku.cn/confluence/pages/viewpage.action?pageId=137172780
if (Constant.obsPridelFileConfigs.contains(subBusi.toLowerCase())) {
obsLogicPrefix = '$obsLogicPrefix/pridel/user/';
} else {
obsLogicPrefix = '$obsLogicPrefix/unpridel/user/';
}
return '$obsLogicPrefix$year$month$day/app/$classCode/$busiCode/$userCode';
}
String _addPreUrl(String location) {
// /bxe-pics/d2/pridel/user/20251017/bxe/bxe_homework/f4ea233d-9e1b-4a3f-bc8f-b64e776f42a6.jpg
if (location.startsWith('/bxe-files')) {
return 'https://files-obs.banxiaoer.com${location.substring(10)}';
} else if (location.startsWith('/bxe-pics')) {
return 'https://pics-obs.banxiaoer.com${location.substring(9)}';
} else if (location.startsWith('/bxe-video')) {
return 'https://video-obs.banxiaoer.com${location.substring(10)}';
} else {
return location;
}
}
/// 生成封面
Future<void> _genHwVideoCover(Dio dio, String keys) async {
try {
var headers = {
"api-key": 'FJ9qv53Bxp',
};
var params = {
"videoKeys": [keys],
"outputSuffix": "_p1",
};
await dio.post(
'${Constant.bxeBaseUrl}/go/mpc/create_covers',
data: jsonEncode(params),
options: Options(
headers: headers,
contentType: 'application/json',
responseType: ResponseType.json,
),
);
} catch (e) {
debugPrint(e.toString());
}
}
}
......@@ -4,6 +4,7 @@ import 'package:appframe/bloc/web_cubit.dart';
import 'package:appframe/config/locator.dart';
import 'package:appframe/data/models/message/h5_message.dart';
import 'package:appframe/data/models/message/h5_resp.dart';
import 'package:appframe/data/repositories/message/upload_start_handler.dart';
// 消息处理器抽象类
abstract class MessageHandler {
......@@ -56,11 +57,17 @@ class MessageDispatcher {
h5Message.cmd == "goLogin" ||
h5Message.cmd.startsWith("setTitlebar") ||
h5Message.cmd == "audioPlay" ||
h5Message.cmd == "openLink") {
h5Message.cmd == "openLink" ||
h5Message.cmd == "uploadStart") {
handler.setCubit(webCubit!);
handler.setMessage(message);
}
// 针对 uploadStart 指令
if (h5Message.cmd == "uploadStart" && handler is UploadStartHandler) {
handler.setCmdUnique(h5Message.unique);
}
final result = await handler.handleMessage(h5Message.params);
// 有些命令需要通过监听器调用Cubit,触发调用时不需要返回结果,不处理回调
if (result == null) {
......
import 'dart:io';
import 'dart:async';
import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
import 'package:ffmpeg_kit_flutter_new/statistics.dart';
class VideoUtil {
///
/// 将视频格式转换为mp4
/// 转码的同时,进行压缩
/// [onProgress] 进度回调,值范围 0.0 ~ 1.0
///
static Future<bool> convertToMp4(String inputPath, String outputPath) async {
static Future<bool> convertToMp4(
String inputPath,
String outputPath, {
void Function(int progress)? onProgress,
}) async {
// 先获取视频总时长(微秒)
final duration = await _getVideoDuration(inputPath);
String cmd;
if (Platform.isIOS) {
cmd = '-i "$inputPath" '
......@@ -24,20 +34,47 @@ class VideoUtil {
'-crf 28 ' // 设置恒定速率因子CRF为28(中等压缩质量)
'-c:a aac ' // 设置音频编码器为AAC
'-b:a 128k ' // 设置音频比特率为128kbps
'-preset fast '
'-threads 0 '
'-strict experimental ' // 允许使用实验性编解码器功能
'-movflags faststart ' // 优化MP4文件结构,使视频可以快速启动播放
'-f mp4 ' // 指定输出格式为MP4
'"$outputPath"'; // 指定输出文件路径
}
final session = await FFmpegKit.execute(cmd);
final completer = Completer<bool>();
FFmpegKit.executeAsync(
cmd,
(session) async {
final returnCode = await session.getReturnCode();
return ReturnCode.isSuccess(returnCode);
completer.complete(ReturnCode.isSuccess(returnCode));
},
null,
(Statistics statistics) {
if (onProgress != null && duration > 0) {
final currentTime = statistics.getTime();
final progress = (currentTime / duration).clamp(0.0, 1.0);
onProgress((progress * 100).floor());
}
},
);
return completer.future;
}
///
/// 通过 ffmpeg 压缩视频
/// [onProgress] 进度回调,值范围 0.0 ~ 1.0
///
static Future<bool> compressVideo(String inputPath, String outputPath, String quality) async {
static Future<bool> compressVideo(
String inputPath,
String outputPath,
String quality, {
void Function(int progress)? onProgress,
}) async {
final duration = await _getVideoDuration(inputPath);
// 使用CRF模式进行压缩,值范围0-51,建议值18-28
// 高质量: CRF 18-20
// 中等质量: CRF 23-26
......@@ -61,12 +98,50 @@ class VideoUtil {
'-crf $crf ' // 恒定速率因子(质量控制)
'-c:a aac ' // 音频编码器
'-b:a 128k ' // 音频比特率
'-preset medium ' // 编码预设
// '-preset medium ' // 编码预设
'-preset fast ' // 编码预设,编码速度显著提升,体积损失很小
'-threads 0 ' // 让 libx264 自动使用所有可用 CPU 核心,默认行为可能只用单核
'-movflags faststart ' // 优化MP4文件结构
'"$outputPath"'; // 输出文件
final session = await FFmpegKit.execute(cmd);
final completer = Completer<bool>();
FFmpegKit.executeAsync(
cmd,
(session) async {
final returnCode = await session.getReturnCode();
return ReturnCode.isSuccess(returnCode);
completer.complete(ReturnCode.isSuccess(returnCode));
},
null,
(Statistics statistics) {
if (onProgress != null && duration > 0) {
final currentTime = statistics.getTime();
final progress = (currentTime / duration).clamp(0.0, 1.0);
onProgress((progress * 100).floor());
}
},
);
return completer.future;
}
/// 获取视频总时长,返回毫秒
static Future<int> _getVideoDuration(String videoPath) async {
final session = await FFmpegKit.execute(
'-i "$videoPath"',
);
final output = await session.getOutput();
// 从 ffmpeg 输出中解析时长,格式如: Duration: 00:01:23.45
final regex = RegExp(r'Duration:\s*(\d+):(\d+):(\d+)\.(\d+)');
final match = regex.firstMatch(output ?? '');
if (match != null) {
final hours = int.parse(match.group(1)!);
final minutes = int.parse(match.group(2)!);
final seconds = int.parse(match.group(3)!);
final ms = int.parse(match.group(4)!);
return (hours * 3600 + minutes * 60 + seconds) * 1000 + ms * 10;
}
return 0;
}
/// 为视频文件生成缩略图
......
Styling with Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!