Flutter開(kāi)發(fā)Mac桌面應(yīng)用實(shí)現(xiàn)自動(dòng)提取生成視頻字幕文件
前言
前段時(shí)間準(zhǔn)備做一個(gè)視頻,最后需要添加字幕,手動(dòng)添加太麻煩了就想在網(wǎng)上找一個(gè)能自動(dòng)提取字幕的軟件或服務(wù),確實(shí)是找到了,但是免費(fèi)版基本上都有諸多限制,比如現(xiàn)在視頻時(shí)長(zhǎng)等等,后來(lái)在 Github 找到一個(gè)開(kāi)源的版本是使用云平臺(tái)的語(yǔ)音識(shí)別實(shí)現(xiàn)的,云服務(wù)的語(yǔ)音識(shí)別是有免費(fèi)的額度的,對(duì)于個(gè)人使用來(lái)說(shuō)一般是夠用了,項(xiàng)目地址:video-srt-windows ,大致實(shí)現(xiàn)流程如下:
- 使用 ffmpeg 提取視頻的音頻文件
- 將音頻文件上傳到云平臺(tái)的對(duì)象存儲(chǔ)
- 調(diào)用云平臺(tái)的語(yǔ)音識(shí)別 api 進(jìn)行文字識(shí)別
- 生成字幕文件
下載 release 版本測(cè)試了一下效果還可以,只需要修改個(gè)別識(shí)別有誤的詞就行,功能完全滿足我的需求;但是遺憾的是該項(xiàng)目只提供了 Windows 版本,而沒(méi)有 Mac 版本的 ,雖然作者也提供了一個(gè) CLI 命令行版本可以在 Mac 上使用,但是對(duì)于普通用戶來(lái)說(shuō)使用起來(lái)還是不是很方便,于是產(chǎn)生了開(kāi)發(fā)一個(gè) Mac 版。
思路
該開(kāi)源項(xiàng)目作者是用 Go 語(yǔ)言寫(xiě)的,我本人擅長(zhǎng)的是 Flutter 開(kāi)發(fā),所以首先想到的就是通過(guò) Flutter 開(kāi)發(fā)一個(gè) Mac 版的桌面應(yīng)用,將 CLI 項(xiàng)目通過(guò) Go 編譯成 Mac 的可執(zhí)行文件內(nèi)置到 Flutter 項(xiàng)目中,再通過(guò) Dart 調(diào)用 shell 命令進(jìn)行執(zhí)行從而實(shí)現(xiàn)軟件的功能。
效果

實(shí)現(xiàn)
下面就來(lái)看看整個(gè)項(xiàng)目是如何一步步最終實(shí)現(xiàn)上面的效果的。
編譯 Mac 版可執(zhí)行文件
首先將 CLI 項(xiàng)目 clone 到本地,然后使用 go build命令編譯對(duì)應(yīng)平臺(tái)的可執(zhí)行文件,如下:
# Mac M1/M2 Arm 架構(gòu) CPU CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o video-srt-arm64 main.go # Mac Amd 架構(gòu) CPU CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o video-srt-amd64 main.go
執(zhí)行以上文件分別生成 arm 和 amd 架構(gòu)的可執(zhí)行文件 video-srt-arm64 和 video-srt-amd64。
內(nèi)置可執(zhí)行文件和 ffmpeg
將上一步生成的對(duì)應(yīng)平臺(tái)的可執(zhí)行文件修改為 video-srt 和配置文件 config.ini以及 ffmpeg文件放到一個(gè)文件夾中打包成 video-srt.zip壓縮包減少包體積。
因?yàn)轫?xiàng)目需要使用到 ffmpeg ,所以需要把 ffmpeg 也內(nèi)置到項(xiàng)目中

通過(guò) Xcode 將 video-srt.zip文件添加到項(xiàng)目的 Resources 文件夾下

然后就是通過(guò)代碼在程序啟動(dòng)時(shí)將內(nèi)置的壓縮包解壓到指定位置,這里解壓使用了 archive庫(kù),核心代碼如下:
// 目錄名稱
const String VIDEO_SRT = "video-srt";
class ZipRepository{
static Future<void> unzip(String zipFile, String targetDir) async{
final inputStream = InputFileStream(zipFile);
final archive = ZipDecoder().decodeBuffer(inputStream);
extractArchiveToDisk(archive, targetDir);
return;
}
static Future<void> unzipVideoSrt() async{
var workDirPath = await PathUtils.getWorkDirPath();
// 創(chuàng)建工作目錄下的 video-srt 目錄
var videoSrtFile = Directory("$workDirPath/$VIDEO_SRT");
// 如果已經(jīng)存在則不重復(fù)解壓
if(await videoSrtFile.exists()){
return;
}
// 解壓
await unzip(VIDEO_SRT_ZIP_PATH, "$workDirPath");
return;
}
}
這里還用到了 path_provider庫(kù)用于獲取相關(guān)目錄:
// 工作目錄名稱
const String WORK_DIR_NAME = "videoSrt";
class PathUtils{
static String? workDirPath;
static Future<String> getWorkDirPath() async{
if(workDirPath != null){
return workDirPath!;
}
// 獲取 library 目錄
Directory tempDir = await getLibraryDirectory();
var workDir = "${tempDir.path}/$WORK_DIR_NAME";
var dir = Directory(workDir);
if(! (await dir.exists())){
await dir.create();
}
workDirPath = workDir;
return workDir;
}
}
在應(yīng)用啟動(dòng)時(shí)調(diào)用解壓將內(nèi)置的 video-srt.zip 內(nèi)容解壓到系統(tǒng) library 下的 videoSrt 目錄下。
設(shè)置配置信息
video-srt的配置是用的 config.ini文件存儲(chǔ)的,所以在代碼里需要讀寫(xiě) ini 文件,這里使用了一個(gè) ini的三方庫(kù),config.ini里包含如下配置內(nèi)容:
#字幕相關(guān)設(shè)置 [srt] #智能分段處理:true(開(kāi)啟) false(關(guān)閉) intelligent_block=true #阿里云Oss對(duì)象服務(wù)配置 #文檔:https://help.aliyun.com/document_detail/31827.html?spm=a2c4g.11186623.6.582.4e7858a85Dr5pA [aliyunOss] # OSS 對(duì)外服務(wù)的訪問(wèn)域名 endpoint= # 存儲(chǔ)空間(Bucket)名稱 bucketName= # 存儲(chǔ)空間(Bucket 域名)地址 bucketDomain= accessKeyId= accessKeySecret= #阿里云語(yǔ)音識(shí)別配置 #文檔: [aliyunClound] # 在管控臺(tái)中創(chuàng)建的項(xiàng)目Appkey,項(xiàng)目的唯一標(biāo)識(shí) appKey= accessKeyId= accessKeySecret=
這里創(chuàng)建一個(gè) ConfigModel用于存放相關(guān)配置,然后使用 ini 庫(kù)的 Config 進(jìn)行讀寫(xiě)封裝,代碼如下 :
// 讀取配置數(shù)據(jù)
static Future<ConfigModel> readIniData() async{
var workDir = await PathUtils.getWorkDirPath();
var iniPath = "$workDir/$VIDEO_SRT/$CONFIG_NAME";
Completer<ConfigModel> completer = Completer();
File(iniPath).readAsLines()
.then((lines) => Config.fromStrings(lines))
.then((Config config){
var iniModel = ConfigModel();
iniModel.intelligent_block = (config.get("srt", "intelligent_block") ?? "true").toLowerCase() == "true";
iniModel.oss_endpoint = config.get("aliyunOss", "endpoint");
iniModel.oss_bucketName = config.get("aliyunOss", "bucketName") ;
iniModel.oss_bucketDomain = config.get("aliyunOss", "bucketDomain") ;
iniModel.oss_accessKeyId = config.get("aliyunOss", "accessKeyId") ;
iniModel.oss_accessKeySecret = config.get("aliyunOss", "accessKeySecret") ;
iniModel.voice_appKey = config.get("aliyunClound", "appKey") ;
iniModel.voice_accessKeyId = config.get("aliyunClound", "accessKeyId") ;
iniModel.voice_accessKeySecret = config.get("aliyunClound", "accessKeySecret") ;
iniModel.go_path = config.get("go", "goPath") ;
completer.complete(iniModel);
});
return completer.future;
}
// 寫(xiě)配置數(shù)據(jù)
static Future<void> writeIniData(ConfigModel iniModel) async{
Config config = Config();
config.addSection("srt");
config.set("srt", "intelligent_block", iniModel.intelligent_block.toString());
config.addSection("aliyunOss");
config.set("aliyunOss", "endpoint", iniModel.oss_endpoint ?? "");
config.set("aliyunOss", "bucketName", iniModel.oss_bucketName ?? "");
config.set("aliyunOss", "bucketDomain", iniModel.oss_bucketDomain ?? "");
config.set("aliyunOss", "accessKeyId", iniModel.oss_accessKeyId ?? "");
config.set("aliyunOss", "accessKeySecret", iniModel.oss_accessKeySecret ?? "");
config.addSection("aliyunClound");
config.set("aliyunClound", "appKey", iniModel.voice_appKey ?? "");
config.set("aliyunClound", "accessKeyId", iniModel.voice_accessKeyId ?? "");
config.set("aliyunClound", "accessKeySecret", iniModel.voice_accessKeySecret ?? "");
config.addSection("go");
config.set("go", "goPath", iniModel.go_path ?? "");
var workDir = await PathUtils.getWorkDirPath();
var iniPath = "$workDir/$VIDEO_SRT/$CONFIG_NAME";
await File(iniPath).writeAsString(config.toString());
return;
}
執(zhí)行命令
配置也寫(xiě)好了,接下來(lái)就需要執(zhí)行編譯好的 video-srt 命令來(lái)提取視頻字幕,這里使用 shell 命令來(lái)執(zhí)行,用到了 process_run庫(kù),核心代碼如下:
static Future<void> runVideoSrt(String targetFilePath, Function(String) callback) async{
if(targetFilePath.isEmpty){
return;
}
// 獲取工作目錄
var workDir = await PathUtils.getWorkDirPath();
var controller = ShellLinesController();
var shell = Shell(stdout: controller.sink, verbose: false);
// 切換路徑到工作目錄下的 video-srt 下
shell = shell.pushd("$workDir/$VIDEO_SRT");
try {
// 給 ffmpeg 添加執(zhí)行權(quán)限
await shell.run("chmod +x ffmpeg");
// 給 video-srt 添加執(zhí)行權(quán)限
await shell.run("chmod +x video-srt");
} on ShellException catch (_) {
// We might get a shell exception
}
// 監(jiān)聽(tīng)執(zhí)行結(jié)果
controller.stream.listen((event) {
callback(event);
});
try {
// 執(zhí)行視頻提取字幕命令
await shell.run("./video-srt $targetFilePath");
} on ShellException catch (_) {
// We might get a shell exception
}
shell = shell.popd();
return;
}
UI 實(shí)現(xiàn)
核心功能實(shí)現(xiàn)了,接下來(lái)就是完成界面的開(kāi)發(fā),讓我們可以方便的進(jìn)行相關(guān)配置和選擇要生成字幕的視頻文件。
為了實(shí)現(xiàn) Mac 風(fēng)格的界面,這里使用了 macos_ui庫(kù),可以讓我們更快捷的實(shí)現(xiàn)相關(guān)界面。
界面分成兩部分,左邊菜單和右邊內(nèi)容展示區(qū)域,效果如下:

代碼如下:
class MainView extends StatefulWidget {
const MainView({super.key});
@override
State<MainView> createState() => _MainViewState();
}
class _MainViewState extends State<MainView> {
int _pageIndex = 0;
@override
Widget build(BuildContext context) {
return PlatformMenuBar(
menus: const [
PlatformMenu(
label: 'VideoSrtMacos',
menus: [
// 狀態(tài)欄左上角退出按鈕
PlatformProvidedMenuItem(
type: PlatformProvidedMenuItemType.quit,
),
],
),
],
child: MacosWindow(
sidebar: Sidebar(
minWidth: 200,
builder: (context, scrollController) => SidebarItems(
currentIndex: _pageIndex,
onChanged: (index) {
setState(() => _pageIndex = index);
},
items: const [
SidebarItem(leading: MacosIcon(CupertinoIcons.home),label: Text('首頁(yè)'),),
SidebarItem(leading: MacosIcon(CupertinoIcons.settings),label: Text('配置'),),
SidebarItem(leading: MacosIcon(CupertinoIcons.helm),label: Text('幫助'),),
SidebarItem(leading: MacosIcon(CupertinoIcons.info),label: Text('關(guān)于'),),
],
),
),
child: IndexedStack(
index: _pageIndex,
children: const [
// 主頁(yè)
HomePage(),
// 配置頁(yè)面
ConfigView(),
HelpView(),
AboutView()
],
),
),
);
}
}
然后分別實(shí)現(xiàn)對(duì)應(yīng)的子界面即可實(shí)現(xiàn)整個(gè)完整的功能,這部分就是純粹的 flutter 界面開(kāi)發(fā)的內(nèi)容了,這里就不過(guò)多贅述了。
最后
雖然使用 Flutter 進(jìn)行開(kāi)發(fā)已經(jīng)很久了,但是更多還是進(jìn)行 Android、iOS 的開(kāi)發(fā),桌面端雖然也寫(xiě)過(guò)一些Demo,但是還未真正使用 Flutter 去開(kāi)發(fā)一個(gè)桌面應(yīng)用,雖然這個(gè)項(xiàng)目功能很簡(jiǎn)單但也算是一個(gè)不錯(cuò)的練手項(xiàng)目。
Github 地址:video-srt-mac
以上就是Flutter開(kāi)發(fā)Mac桌面應(yīng)用實(shí)現(xiàn)自動(dòng)提取生成視頻字幕文件的詳細(xì)內(nèi)容,更多關(guān)于Flutter Mac提取視頻字幕的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android實(shí)現(xiàn)手機(jī)震動(dòng)抖動(dòng)效果的方法
今天小編就為大家分享一篇關(guān)于Android實(shí)現(xiàn)手機(jī)震動(dòng)抖動(dòng)效果的方法,小編覺(jué)得內(nèi)容挺不錯(cuò)的,現(xiàn)在分享給大家,具有很好的參考價(jià)值,需要的朋友一起跟隨小編來(lái)看看吧2019-03-03
SurfaceView實(shí)現(xiàn)紅包雨平移動(dòng)畫(huà)
這篇文章主要為大家詳細(xì)介紹了SurfaceView實(shí)現(xiàn)紅包雨平移動(dòng)畫(huà),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-07-07
Android圖片無(wú)限輪播的實(shí)現(xiàn)代碼
這篇文章主要為大家詳細(xì)介紹了Android圖片無(wú)限輪播的實(shí)現(xiàn)代碼,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-12-12
Android仿iOS實(shí)現(xiàn)側(cè)滑返回功能(類似微信)
這篇文章主要為大家詳細(xì)介紹了Android仿iOS實(shí)現(xiàn)側(cè)滑返回功能,類似微信功能,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-12-12
Android開(kāi)發(fā)Flutter?桌面應(yīng)用窗口化實(shí)戰(zhàn)示例
這篇文章主要為大家介紹了Android開(kāi)發(fā)Flutter?桌面應(yīng)用窗口化實(shí)戰(zhàn)示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-09-09
Android手機(jī)上同時(shí)安裝正式包與測(cè)試包的方法
這篇文章主要給大家介紹了關(guān)于Android手機(jī)上同時(shí)安裝正式包與測(cè)試包的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-02-02
Android之獲取手機(jī)內(nèi)部及sdcard存儲(chǔ)空間的方法
今天小編就為大家分享一篇Android之獲取手機(jī)內(nèi)部及sdcard存儲(chǔ)空間的方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-08-08
android自定義view實(shí)現(xiàn)推箱子小游戲
這篇文章主要為大家詳細(xì)介紹了android自定義view實(shí)現(xiàn)推箱子小游戲,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-04-04
Android仿微信5實(shí)現(xiàn)滑動(dòng)導(dǎo)航條
這篇文章主要為大家詳細(xì)介紹了Android仿微信5實(shí)現(xiàn)滑動(dòng)導(dǎo)航條,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-08-08

