likes
comments
collection
share

Electron + Vite + Vue3 + ts 打造 Mp4 转换器

作者站长头像
站长
· 阅读数 18

之前用 Node.js 开发了一款在线版 Mp4 转换器,有同学反映需要本地要安装 ffmpeg,使用起来比较麻烦。其实,我们可以将该应用转换为 Elctron 桌面版,并将 ffmpeg 打包进去做成便携版,这样不用安装,还不用连网。

最终效果如下:

Electron + Vite + Vue3 + ts 打造 Mp4 转换器

electron-vite-vue 模板

本项目有两个版本:electron-vite-vue 脚手架版JS 原生版。原生版在渲染进程模块使用了大量的模板字符串,没有脚手架版那么方便。毕竟 Vue 模板可以绑定数据。基于数据驱动是现代 JS 框架的精髓。

模板是基于 Vite 官方的 template-vue-ts 脚手架搭建的,所以绝大部分都是 Vite + Vue3 + ts 工程的文件(可以看作Vue 前端,其中 App.vue 是根组件,渲染进程模块的大部分逻辑都写在这里),除了:

  • electron 文件夹:可以看作 Node 后端,其中 main.ts 是主入口,主进程模块的大部分逻辑都写在这里。
  • electron-builder.json5:Electron 专属打包配置文件,JS 原生版的导报配置式放在 package.json 中。

有了 Vite 的加持,可以使用 Node.js ESM 包。

在生成的模板中集成 element-plus 有点问题:tsconfig.json 配置项 moduleResolution 设置成 Bundler 呼不出代码提示,我改成 Node 后就可以了。

如你所见,本质上,Electron 开发就是 JS 全栈开发。

桌面版的特性

桌面版和在线版的 Mp4 转换器相比,我们共用了视频读取、视频转换代码逻辑。其他部分或多或少有些差别,毕竟桌面版有不少原生操作,用户体验体验更好:

  • 菜单操作:桌面独有的功能,添加了 视频文件帮助 两个操作入口
  • 多窗口操作:为方便显示,预览视频单独使用了一个窗口
  • 选择视频文件使用 Electron 原生 dialog:不用上传视频后才能读取视频信息
  • 新增本地保存视频文件功能:同样使用 Elctron 原生 dialog
  • 使用 Element-plus UI 库:是不是比在线版漂亮一些?
  • 使用进程间通信(IPC):不用调用 Web API,离线也能转换

转换成功后会直接打开预览视频,因为一旦将视频读入内存,再次转换很快就能完成,所以没有做预览视频的其他入口。

自定义菜单

本项目中,我们使用 Menu.buildFromTemplate(menuTemplate) 来自定义原生应用菜单,template 是一个选项类型的数组,用于构建 MenuItem。通过模板创建的原生菜单是基于数据驱动的:

// electron/config.ts
import { MenuItem, MenuItemConstructorOptions, shell } from 'electron'

const isMac = process.platform === 'darwin'
const menuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [
  {
    label: '视频文件',
    submenu: [
      // ...
      {
        id: 'saveFile',
        label: '保存视频',
        accelerator: 'CmdOrCtrl+S',
        enabled: false
      },
      { type: 'separator' },
      isMac
        ? {
            role: 'close',
            label: '退出',
            accelerator: 'Cmd+Q'
          }
        : {
            role: 'quit',
            label: '退出',
            accelerator: 'Ctrl+Q'
          }
    ]
  },
  {
    label: '帮助',
    submenu: [
      // ...
      {
        id: 'support',
        label: '技术支持',
        click() {
          shell.openExternal('mailto:riafan@hotmail.com')
        }
      }
    ]
  }
]

export { menuTemplate }

在 macOS 上将 menu 设置成应用内菜单,在 Windows 和 Linux 上,menu 将会被设置成窗口顶部菜单。

对于 MenuItem 来说,除了设置 idlabel 属性外,在本项目中,我们还设置了:

  • accelerator 快捷键:设置为 CmdOrCtrl+S,表示 macOS 上按 Cmd+S,Windows 上按 Ctrl+S 会保存视频
  • enabled 是否激活:本项目中,保存视频 菜单项默认是禁用的,只有打开过视频才是激活的
  • click 点击菜单项的回调函数:本项目中,点击技术支持菜单项会打开系统发送电子邮件的默认程序
  • role 定义菜单项的操作: 本项目中,设置 role: 'close' 会调用系统默认的退出应用操作,省去了对各系统分别设置 click 属性
  • type 定义菜单项类型:默认为 normal。设置为 separator 会在菜单项之间显示一条分割线

指定 click 属性,role 属性将被忽略。

打开视频

进程间通信 (IPC) 是在 Electron 中构建功能丰富的桌面应用程序的关键部分之一。 由于主进程和渲染器进程在 Electron 的进程模型具有不同的职责,因此 IPC 是执行许多常见任务的唯一方法。

下面是打开视频的时序图:

Electron + Vite + Vue3 + ts 打造 Mp4 转换器

对于打开视频来说,可能是通过选择菜单项、点击选择按钮或是拖放到按钮区触发的。点击选择按钮或是拖放到按钮区是从渲染线程发起的,选择视频后会返回视频的文件路径,因此应该使用渲染器进程到主进程双向通信模式。而选择菜单项是从主线程发起的,可以使用主进程到渲染器进程单向通信模式。渲染器接收消息后可以复用双向通信模式,一样可以返回视频的文件路径。下面使用 contextBridge API 将这段代码暴露给渲染器进程。

// preload.ts
selectFile: () => ipcRenderer.invoke('dialog:selectFile'),
onSelectFile: (callback: () => void) =>
  ipcRenderer.on('menu:selectFile', callback),

Electron 主进程侧(main.ts)需要使用 showOpenDialog 方法打开对话框选择一个视频文件:

// electron/main.ts
async function handleSelectFile() {
  const { canceled, filePaths } = await dialog.showOpenDialog({
    filters: [{ name: fileType, extensions: allowFormats }]
  })
  if (!canceled) {
    return filePaths[0]
  }
}

filters 用于规定用户可见或可选的特定类型范围,设置filtersextensions 属性会按文件后缀名过滤。如果 extensions 不设置成 ['*'],则对话框没有 所有文件 选项(Web 应用的文件选择框总是有的),因此 Electron 中不必写文件类型检验的逻辑,指定文件类型即可。

showOpenDialog 方法会返回用户选择的文件路径,如果对话框被取消了 ,则返回 undefined

读取、转换视频

这一块代码实现和 Web 版差不多,只是数据通信使用的是 IPC 而不是 Web API。读取、转换视频的预加载脚本如下:

// preload.ts
readFile: (path: string) => ipcRenderer.invoke('video:readFile', path),
convertFile: (params: Params) =>
  ipcRenderer.invoke('video:convertFile', params)

如你所见,readFileconvertFile 都是使用渲染器进程到主进程双向通信模式。

// electron/main.ts
.on('end', () => {
  console.log('file has been converted succesfully')
  createPreview({
    width,
    height
  })
  resolve(output)
})

视频转换成功后会另外创建一个窗口来预览视频。

预览视频

当然,创建预览窗口是在 Electron 主进程侧(main.ts)完成的。

// electron/main.ts
const win = new BrowserWindow({
  width: width + 16,
  height: height + 88,
  webPreferences: {
    preload: path.join(__dirname, 'preview.js')
  }
})

if (isDev) {
  win.loadURL(path.posix.join(VITE_DEV_SERVER_URL, 'preview.html'))
} else {
  win.loadFile(path.join(process.env.DIST, 'preview.html'))
}
// ...
win.removeMenu()

每个 Electron 应用都会为每个打开的应用程序窗口 ( 与每个网页嵌入 ) 生成一个单独的渲染器进程,多窗口意味着多渲染器进程。通常新建应用程序窗口需要初始化窗口的宽高、预加载脚本和加载页面。

此处的预览窗口宽高是根据视频宽高计算出来的。为了安全,我们还新建了预加载脚本 preview.js,在应用程序窗口构造方法中的 webPreferences 选项里将其附加到主进程。注意,我们还需要为其额外配置一个入口,可以在 vite.config.ts 配置:

electron([
  // ...
  {
    entry: 'electron/preview.ts',
  }
])

预加载脚本 preview.ts 的代码如下:

// electron/preview.ts
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('electronAPI', {
  previewFile: (callback: () => void) =>
    ipcRenderer.on('video:preview', callback),
  saveFile: (callback: () => void) =>
    ipcRenderer.invoke('dialog:saveFile', callback)
})

注意:此处的 saveFile 回调函数与 'electron/preload.ts' 中的 saveFile 回调函数是一样的。

因为加载了新页面 preview.html,但 Vite 默认是单页面的,只有 index.html 这个单一入口,所以我们还需要为其额外配置一个入口,可以在 vite.config.ts 配置:

build: {
  rollupOptions: {
    input: {
      index: path.join(__dirname, 'index.html'),
      preview: path.join(__dirname, 'preview.html'),
    }
  }
}

注意:开发环境下运行本脚手架,加载页面一定要使用服务器地址。

预览窗口不需要菜单,我们使用 win.removeMenu() 将其移除。

保存视频

下面是保存视频的时序图:

Electron + Vite + Vue3 + ts 打造 Mp4 转换器

和打开视频类似,我们需要定义其预加载脚本:

// preload.ts
saveFile: () => ipcRenderer.invoke('dialog:saveFile'),
onSaveFile: (callback: () => void) =>
  ipcRenderer.on('menu:saveFile', callback)

Electron 主进程侧(main.ts)需要使用 showSaveDialog 方法打开对话框来保存视频文件:

async function handleSaveFile() {
  const { canceled, filePath } = await dialog.showSaveDialog({
    filters: [{ name: fileType, extensions: ['mp4'] }],
    defaultPath: path.join(app.getPath('videos'), `${Date.now()}.mp4`)
  })
  if (!canceled) {
    return new Promise((resolve, reject) => {
      const rs = fs.createReadStream(output)
      const ws = fs.createWriteStream(filePath!)
      rs.pipe(ws)
      rs.on('end', () => {
        resolve('视频保存成功')
      }).on('error', (error: any) => {
        reject(error)
      })
    })
  }
}

保存视频实际上是个拷贝文件的过程,这里使用了 fs 模块和 pipe 操作。

打包应用程序

之前提过,打包是在 electron-builder 中配置的:

{
  directories: {
    output: 'release'
  },
  files: [
    'dist-electron',
    'dist',
    "!**node_modules/ff*-static/bin/!(win32)",
    "!**node_modules/ff*-static/bin/win32/ia32"
  ],
  win: {
    icon: "res/icon.png",
    target: [
      {
        target: 'portable',
        arch: ['x64']
      }
    ],
    "artifactName": "${productName}_${version}.${ext}"
  }
}

我们的目标是打包 win32 平台 x64 架构下的 portable版本,配置很简单,重点说说 files

dist-electron 包含 Node.js 后端打包文件,dist 包含 Vue 前端打包文件。那两个文件正则表达式呢?

使用 Electron 打包的时候设置 asartrue,electron-builder 会智能的把一些 native 的程序(包括 exe执行文件)打包到 app.asar.unpacked中。也就是说,如果不使用文件正则表达式筛选特定平台,会复制 ffmpeg-staticffprobe-static 整个依赖包。那样打包文件就太大了。

注意:我们需要通过替换来获取ffmpeg 二进制文件的路径,代码如下:

// Get the paths to the packaged versions of the binaries we want to use
const ffmpegPath = require('ffmpeg-static').replace(
  'app.asar',
  'app.asar.unpacked'
)
const ffprobePath = require('ffprobe-static').path.replace(
  'app.asar',
  'app.asar.unpacked'
)
// tell the ffmpeg package where it can find the needed binaries.
ffmpeg.setFfmpegPath(ffmpegPath)
ffmpeg.setFfprobePath(ffprobePath)

关于如何在 Electron 应用中包含 ffmpeg 二进制文件,可参考这篇文章

打包文件是个漫长的过程,如何快速验证打包是否正确呢?

electron-builder --dir

运行这个 electron-builder 命令可以快速生成未压缩的打包文件,可以协助排查问题。

本项目如果使用 JS 原生版打包,最终这个便携式 Mp4 转换器只有 70 M 左右。要知道,ffmpeg 那两个二进制文件都有60 M 左右。真是爽歪歪!😆

链接地址