Flutter开发Mac桌面应用实现自动提取生成视频字幕文件

2,188 阅读4分钟

我正在参加「掘金·启航计划」

前言

前段时间准备做一个视频,最后需要添加字幕,手动添加太麻烦了就想在网上找一个能自动提取字幕的软件或服务,确实是找到了,但是免费版基本上都有诸多限制,比如现在视频时长等等,后来在 Github 找到一个开源的版本是使用云平台的语音识别实现的,云服务的语音识别是有免费的额度的,对于个人使用来说一般是够用了,项目地址:video-srt-windows ,大致实现流程如下:

  • 使用 ffmpeg 提取视频的音频文件
  • 将音频文件上传到云平台的对象存储
  • 调用云平台的语音识别 api 进行文字识别
  • 生成字幕文件

下载 release 版本测试了一下效果还可以,只需要修改个别识别有误的词就行,功能完全满足我的需求;但是遗憾的是该项目只提供了 Windows 版本,而没有 Mac 版本的 ,虽然作者也提供了一个 CLI 命令行版本可以在 Mac 上使用,但是对于普通用户来说使用起来还是不是很方便,于是产生了开发一个 Mac 版。

思路

该开源项目作者是用 Go 语言写的,我本人擅长的是 Flutter 开发,所以首先想到的就是通过 Flutter 开发一个 Mac 版的桌面应用,将 CLI 项目通过 Go 编译成 Mac 的可执行文件内置到 Flutter 项目中,再通过 Dart 调用 shell 命令进行执行从而实现软件的功能。

效果

实现

下面就来看看整个项目是如何一步步最终实现上面的效果的。

编译 Mac 版可执行文件

首先将 CLI 项目 clone 到本地,然后使用 go build命令编译对应平台的可执行文件,如下:

# Mac M1/M2 Arm 架构 CPU
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o video-srt-arm64 main.go

# Mac Amd 架构 CPU
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o video-srt-amd64 main.go

执行以上文件分别生成 armamd 架构的可执行文件 video-srt-arm64 和 video-srt-amd64。

内置可执行文件和 ffmpeg

将上一步生成的对应平台的可执行文件修改为 video-srt 和配置文件 config.ini以及 ffmpeg文件放到一个文件夹中打包成 video-srt.zip压缩包减少包体积。

因为项目需要使用到 ffmpeg ,所以需要把 ffmpeg 也内置到项目中

通过 Xcode 将 video-srt.zip文件添加到项目的 Resources 文件夹下

然后就是通过代码在程序启动时将内置的压缩包解压到指定位置,这里解压使用了 archive库,核心代码如下:

// 目录名称
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();
    // 创建工作目录下的 video-srt 目录
    var videoSrtFile = Directory("$workDirPath/$VIDEO_SRT");
    // 如果已经存在则不重复解压
    if(await videoSrtFile.exists()){
      return;
    }
    // 解压
    await unzip(VIDEO_SRT_ZIP_PATH, "$workDirPath");
    return;
  }
}

这里还用到了 path_provider库用于获取相关目录:

// 工作目录名称
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;
  }
}

在应用启动时调用解压将内置的 video-srt.zip 内容解压到系统 library 下的 videoSrt 目录下。

设置配置信息

video-srt的配置是用的 config.ini文件存储的,所以在代码里需要读写 ini 文件,这里使用了一个 ini的三方库,config.ini里包含如下配置内容:

#字幕相关设置
[srt]
#智能分段处理:true(开启) false(关闭)
intelligent_block=true

#阿里云Oss对象服务配置
#文档:https://help.aliyun.com/document_detail/31827.html?spm=a2c4g.11186623.6.582.4e7858a85Dr5pA
[aliyunOss]
# OSS 对外服务的访问域名
endpoint=
# 存储空间(Bucket)名称
bucketName=
# 存储空间(Bucket 域名)地址
bucketDomain=
accessKeyId=
accessKeySecret=

#阿里云语音识别配置
#文档:
[aliyunClound]
# 在管控台中创建的项目Appkey,项目的唯一标识
appKey=
accessKeyId=
accessKeySecret=

这里创建一个 ConfigModel用于存放相关配置,然后使用 ini 库的 Config 进行读写封装,代码如下 :

// 读取配置数据
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;
}

// 写配置数据
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;
}

执行命令

配置也写好了,接下来就需要执行编译好的 video-srt 命令来提取视频字幕,这里使用 shell 命令来执行,用到了 process_run库,核心代码如下:

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 添加执行权限
    await shell.run("chmod +x ffmpeg");
    // 给 video-srt 添加执行权限
    await shell.run("chmod +x video-srt");
  } on ShellException catch (_) {
    // We might get a shell exception
  }
  // 监听执行结果
  controller.stream.listen((event) {
    callback(event);
  });
  try {
    // 执行视频提取字幕命令
    await shell.run("./video-srt $targetFilePath");
  } on ShellException catch (_) {
    // We might get a shell exception
  }
  shell = shell.popd();
  return;
}

UI 实现

核心功能实现了,接下来就是完成界面的开发,让我们可以方便的进行相关配置和选择要生成字幕的视频文件。

为了实现 Mac 风格的界面,这里使用了 macos_ui库,可以让我们更快捷的实现相关界面。

界面分成两部分,左边菜单和右边内容展示区域,效果如下:

代码如下:

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: [
            // 状态栏左上角退出按钮
            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('首页'),),
              SidebarItem(leading: MacosIcon(CupertinoIcons.settings),label: Text('配置'),),
              SidebarItem(leading: MacosIcon(CupertinoIcons.helm),label: Text('帮助'),),
              SidebarItem(leading: MacosIcon(CupertinoIcons.info),label: Text('关于'),),
            ],
          ),
        ),
        child: IndexedStack(
          index: _pageIndex,
          children: const [
            // 主页
            HomePage(),
            // 配置页面
            ConfigView(),
            HelpView(),
            AboutView()
          ],
        ),
      ),
    );
  }
}

然后分别实现对应的子界面即可实现整个完整的功能,这部分就是纯粹的 flutter 界面开发的内容了,这里就不过多赘述了。

最后

虽然使用 Flutter 进行开发已经很久了,但是更多还是进行 Android、iOS 的开发,桌面端虽然也写过一些Demo,但是还未真正使用 Flutter 去开发一个桌面应用,虽然这个项目功能很简单但也算是一个不错的练手项目。

Github 地址:video-srt-mac