Skip to content
Toggle navigation
Toggle navigation
This project
Loading...
Sign in
ethan
/
appframe
Go to a project
Toggle navigation
Toggle navigation pinning
Projects
Groups
Snippets
Help
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Network
Compare
Branches
Tags
Commit 552c2145
authored
2026-04-21 14:55:12 +0800
by
tanghuan
Browse Files
Options
Browse Files
Tag
Download
Email Patches
Plain Diff
上传文件:1.针对视频文件增加转码和压缩的进度反馈;2.增加上传进度反馈。
1 parent
596a373b
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
681 additions
and
11 deletions
lib/bloc/web_cubit.dart
lib/config/constant.dart
lib/config/locator.dart
lib/data/repositories/message/upload_start_handler.dart
lib/services/dispatcher.dart
lib/utils/video_util.dart
lib/bloc/web_cubit.dart
View file @
552c214
...
@@ -408,6 +408,67 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver {
...
@@ -408,6 +408,67 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver {
_controller
.
runJavaScript
(
script
);
_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
()
{
void
finishLoading
()
{
emit
(
state
.
copyWith
(
loaded:
true
));
emit
(
state
.
copyWith
(
loaded:
true
));
}
}
...
...
lib/config/constant.dart
View file @
552c214
...
@@ -24,7 +24,7 @@ class Constant {
...
@@ -24,7 +24,7 @@ class Constant {
/// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
///
///
/// obs文件分片上传的分片大小:5M
/// obs文件分片上传的分片大小:5M
static
const
int
obsUploadChunkSize
=
1024
*
1024
*
5
;
static
const
int
obsUploadChunkSize
=
1024
*
1024
*
1
;
/// obs文件上传的逻辑前缀
/// obs文件上传的逻辑前缀
static
const
String
obsLogicPrefix
=
EnvConfig
.
env
==
'dev'
?
'd2/pridel/user/'
:
'p2/unpridel/user/'
;
static
const
String
obsLogicPrefix
=
EnvConfig
.
env
==
'dev'
?
'd2/pridel/user/'
:
'p2/unpridel/user/'
;
...
...
lib/config/locator.dart
View file @
552c214
...
@@ -30,6 +30,7 @@ import 'package:appframe/data/repositories/message/share_to_wx_handler.dart';
...
@@ -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/storage_handler.dart'
;
import
'package:appframe/data/repositories/message/title_bar_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_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/vibrate_short_handler.dart'
;
import
'package:appframe/data/repositories/message/video_info_handler.dart'
;
import
'package:appframe/data/repositories/message/video_info_handler.dart'
;
import
'package:appframe/data/repositories/message/wifi_info_handler.dart'
;
import
'package:appframe/data/repositories/message/wifi_info_handler.dart'
;
...
@@ -179,6 +180,7 @@ Future<void> setupLocator() async {
...
@@ -179,6 +180,7 @@ Future<void> setupLocator() async {
/// 上传文件
/// 上传文件
getIt
.
registerLazySingleton
<
MessageHandler
>(()
=>
UploadFileHandler
(),
instanceName:
'uploadFile'
);
getIt
.
registerLazySingleton
<
MessageHandler
>(()
=>
UploadFileHandler
(),
instanceName:
'uploadFile'
);
getIt
.
registerLazySingleton
<
MessageHandler
>(()
=>
UploadStartHandler
(),
instanceName:
'uploadStart'
);
/// 下载文件
/// 下载文件
getIt
.
registerLazySingleton
<
MessageHandler
>(()
=>
DownloadFileHandler
(),
instanceName:
'downloadFile'
);
getIt
.
registerLazySingleton
<
MessageHandler
>(()
=>
DownloadFileHandler
(),
instanceName:
'downloadFile'
);
...
...
lib/data/repositories/message/upload_start_handler.dart
0 → 100644
View file @
552c214
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
());
}
}
}
lib/services/dispatcher.dart
View file @
552c214
...
@@ -4,6 +4,7 @@ import 'package:appframe/bloc/web_cubit.dart';
...
@@ -4,6 +4,7 @@ import 'package:appframe/bloc/web_cubit.dart';
import
'package:appframe/config/locator.dart'
;
import
'package:appframe/config/locator.dart'
;
import
'package:appframe/data/models/message/h5_message.dart'
;
import
'package:appframe/data/models/message/h5_message.dart'
;
import
'package:appframe/data/models/message/h5_resp.dart'
;
import
'package:appframe/data/models/message/h5_resp.dart'
;
import
'package:appframe/data/repositories/message/upload_start_handler.dart'
;
// 消息处理器抽象类
// 消息处理器抽象类
abstract
class
MessageHandler
{
abstract
class
MessageHandler
{
...
@@ -56,11 +57,17 @@ class MessageDispatcher {
...
@@ -56,11 +57,17 @@ class MessageDispatcher {
h5Message
.
cmd
==
"goLogin"
||
h5Message
.
cmd
==
"goLogin"
||
h5Message
.
cmd
.
startsWith
(
"setTitlebar"
)
||
h5Message
.
cmd
.
startsWith
(
"setTitlebar"
)
||
h5Message
.
cmd
==
"audioPlay"
||
h5Message
.
cmd
==
"audioPlay"
||
h5Message
.
cmd
==
"openLink"
)
{
h5Message
.
cmd
==
"openLink"
||
h5Message
.
cmd
==
"uploadStart"
)
{
handler
.
setCubit
(
webCubit
!);
handler
.
setCubit
(
webCubit
!);
handler
.
setMessage
(
message
);
handler
.
setMessage
(
message
);
}
}
// 针对 uploadStart 指令
if
(
h5Message
.
cmd
==
"uploadStart"
&&
handler
is
UploadStartHandler
)
{
handler
.
setCmdUnique
(
h5Message
.
unique
);
}
final
result
=
await
handler
.
handleMessage
(
h5Message
.
params
);
final
result
=
await
handler
.
handleMessage
(
h5Message
.
params
);
// 有些命令需要通过监听器调用Cubit,触发调用时不需要返回结果,不处理回调
// 有些命令需要通过监听器调用Cubit,触发调用时不需要返回结果,不处理回调
if
(
result
==
null
)
{
if
(
result
==
null
)
{
...
...
lib/utils/video_util.dart
View file @
552c214
import
'dart:io'
;
import
'dart:io'
;
import
'dart:async'
;
import
'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart'
;
import
'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart'
;
import
'package:ffmpeg_kit_flutter_new/return_code.dart'
;
import
'package:ffmpeg_kit_flutter_new/return_code.dart'
;
import
'package:ffmpeg_kit_flutter_new/statistics.dart'
;
class
VideoUtil
{
class
VideoUtil
{
///
///
/// 将视频格式转换为mp4
/// 将视频格式转换为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
;
String
cmd
;
if
(
Platform
.
isIOS
)
{
if
(
Platform
.
isIOS
)
{
cmd
=
'-i "
$inputPath
" '
cmd
=
'-i "
$inputPath
" '
...
@@ -24,20 +34,47 @@ class VideoUtil {
...
@@ -24,20 +34,47 @@ class VideoUtil {
'-crf 28 '
// 设置恒定速率因子CRF为28(中等压缩质量)
'-crf 28 '
// 设置恒定速率因子CRF为28(中等压缩质量)
'-c:a aac '
// 设置音频编码器为AAC
'-c:a aac '
// 设置音频编码器为AAC
'-b:a 128k '
// 设置音频比特率为128kbps
'-b:a 128k '
// 设置音频比特率为128kbps
'-preset fast '
'-threads 0 '
'-strict experimental '
// 允许使用实验性编解码器功能
'-strict experimental '
// 允许使用实验性编解码器功能
'-movflags faststart '
// 优化MP4文件结构,使视频可以快速启动播放
'-movflags faststart '
// 优化MP4文件结构,使视频可以快速启动播放
'-f mp4 '
// 指定输出格式为MP4
'-f mp4 '
// 指定输出格式为MP4
'"
$outputPath
"'
;
// 指定输出文件路径
'"
$outputPath
"'
;
// 指定输出文件路径
}
}
final
session
=
await
FFmpegKit
.
execute
(
cmd
);
final
returnCode
=
await
session
.
getReturnCode
();
final
completer
=
Completer
<
bool
>();
return
ReturnCode
.
isSuccess
(
returnCode
);
FFmpegKit
.
executeAsync
(
cmd
,
(
session
)
async
{
final
returnCode
=
await
session
.
getReturnCode
();
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 压缩视频
/// 通过 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模式进行压缩,值范围0-51,建议值18-28
// 高质量: CRF 18-20
// 高质量: CRF 18-20
// 中等质量: CRF 23-26
// 中等质量: CRF 23-26
...
@@ -61,12 +98,50 @@ class VideoUtil {
...
@@ -61,12 +98,50 @@ class VideoUtil {
'-crf
$crf
'
// 恒定速率因子(质量控制)
'-crf
$crf
'
// 恒定速率因子(质量控制)
'-c:a aac '
// 音频编码器
'-c:a aac '
// 音频编码器
'-b:a 128k '
// 音频比特率
'-b:a 128k '
// 音频比特率
'-preset medium '
// 编码预设
// '-preset medium ' // 编码预设
'-preset fast '
// 编码预设,编码速度显著提升,体积损失很小
'-threads 0 '
// 让 libx264 自动使用所有可用 CPU 核心,默认行为可能只用单核
'-movflags faststart '
// 优化MP4文件结构
'-movflags faststart '
// 优化MP4文件结构
'"
$outputPath
"'
;
// 输出文件
'"
$outputPath
"'
;
// 输出文件
final
session
=
await
FFmpegKit
.
execute
(
cmd
);
final
returnCode
=
await
session
.
getReturnCode
();
final
completer
=
Completer
<
bool
>();
return
ReturnCode
.
isSuccess
(
returnCode
);
FFmpegKit
.
executeAsync
(
cmd
,
(
session
)
async
{
final
returnCode
=
await
session
.
getReturnCode
();
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
;
}
}
/// 为视频文件生成缩略图
/// 为视频文件生成缩略图
...
...
Write
Preview
Styling with
Markdown
is supported
Attach a file
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to post a comment