Flutter開發(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è)開源的版本是使用云平臺(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)生了開發(fā)一個(gè) Mac 版。
思路
該開源項(xiàng)目作者是用 Go 語(yǔ)言寫的,我本人擅長(zhǎng)的是 Flutter 開發(fā),所以首先想到的就是通過(guò) Flutter 開發(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ǔ)的,所以在代碼里需要讀寫 ini 文件,這里使用了一個(gè) ini
的三方庫(kù),config.ini
里包含如下配置內(nèi)容:
#字幕相關(guān)設(shè)置 [srt] #智能分段處理:true(開啟) 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)行讀寫封裝,代碼如下 :
// 讀取配置數(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; } // 寫配置數(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í)行命令
配置也寫好了,接下來(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)聽執(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)就是完成界面的開發(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 界面開發(fā)的內(nèi)容了,這里就不過(guò)多贅述了。
最后
雖然使用 Flutter 進(jìn)行開發(fā)已經(jīng)很久了,但是更多還是進(jìn)行 Android、iOS 的開發(fā),桌面端雖然也寫過(guò)一些Demo,但是還未真正使用 Flutter 去開發(fā)一個(gè)桌面應(yīng)用,雖然這個(gè)項(xiàng)目功能很簡(jiǎn)單但也算是一個(gè)不錯(cuò)的練手項(xiàng)目。
Github 地址:video-srt-mac
以上就是Flutter開發(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) 拍照 選擇圖片 剪裁等代碼分享
本文給大家分享了2個(gè)安卓實(shí)現(xiàn)實(shí)現(xiàn) 拍照 選擇圖片 剪裁等的代碼,都是從正式項(xiàng)目中提取出來(lái)了,非常實(shí)用,有需要的小伙伴可以參考下。2016-01-01android ListView和GridView拖拽移位實(shí)現(xiàn)代碼
有些朋友對(duì)android中ListView和GridView拖拽移位功能的實(shí)現(xiàn)不是很了解,接下來(lái)將詳細(xì)介紹,需要了解的朋友可以參考下2012-12-12Android studio 連接手機(jī)調(diào)試操作步驟
這篇文章主要介紹了Android studio 連接手機(jī)調(diào)試操作步驟,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-05-05Android實(shí)現(xiàn)仿通訊錄側(cè)邊欄滑動(dòng)SiderBar效果代碼
這篇文章主要介紹了Android實(shí)現(xiàn)仿通訊錄側(cè)邊欄滑動(dòng)SiderBar效果代碼,實(shí)例分析了通訊錄側(cè)邊欄滑動(dòng)效果的實(shí)現(xiàn)技巧,并附帶完整實(shí)例代碼供讀者下載參考,需要的朋友可以參考下2015-10-10詳解Flutter桌面應(yīng)用如何進(jìn)行多分辨率適配
這篇文章主要為大家介紹了Flutter桌面應(yīng)用如何進(jìn)行多分辨率適配的方法詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-02-02Android開發(fā)實(shí)現(xiàn)ImageView寬度頂邊顯示,高度保持比例的方法
這篇文章主要介紹了Android開發(fā)實(shí)現(xiàn)ImageView寬度頂邊顯示,高度保持比例的方法,結(jié)合實(shí)例形式分析了Android ImageView界面布局及元素屬性動(dòng)態(tài)操作兩種功能實(shí)現(xiàn)技巧,需要的朋友可以參考下2018-02-02Android AnalogClock簡(jiǎn)單使用方法實(shí)例
這篇文章主要介紹了Android AnalogClock簡(jiǎn)單使用方法,結(jié)合實(shí)例形式簡(jiǎn)單分析了AnalogClock的布局調(diào)用技巧,需要的朋友可以參考下2016-01-01