Limboy

通过视频片段来学习英语

「背单词」是件很有挑战的事情,尤其是在出了校园之后,持续下去更是困难。那如何让这个过程多点乐趣呢?然后就想到了视频:如果把包含对应单词的影视片段裁剪出来,通过看视频的方式会不会让「背单词」不那么枯燥?一方面可以对单词的上下文可以有更多的了解,另外也正好可以回顾这些作品。

感觉可行,怎么做呢?其实也简单,从字幕文件入手,字幕包含了单词,也有 Time Offset,拿到这些信息后,找到对应的视频文件,通过 ffmpeg 去截取视频片段就行了。

第一步:下载影视剧和字幕

这一步主要是体力活,把字幕和视频文件名对应。有个小技巧:通过 rename 命令可以根据正则批量重命名文件(需要先通过 brew 安装)

# $n 可以反向引用前面括号里的内容
# -n 选项可以预览重命名后的效果(dry run)

# 「老友记.Friends.s01e07.ass」将会被重命名为「Friends.S01E07.ass」
rename 's/老友记.(Friends.)s([\d]+)e([\d]+)/$1S$2E$3/g' *

第二步:写脚本

这个脚本的目标是:

  1. 找到包含该单词的视频,并解析出开始时间
  2. 通过 ffmpeg 截取视频片段

其中 .ass 文件每一行的字幕格式如下:

Dialogue: 0,0:11:38.57,0:11:39.84,*Default,NTP,0000,0000,0000,,沃尔特  来看看\NCheck it out Walt.

所以只要定位到包含该单词的行,然后找到符合特征(dd:dd:dd.dd)的字符串即可。这里遇到一个小问题,在打开某些字幕文件时会出现乱码,通过 Hex Editor 看了下长这样:

BOM 头是 0xFFFE(小端序),同时还有用来占位的 00,是 utf16le 编码的文件。所以在判断该用哪种编码打开时要格外注意。

第三步:截取视频

这一部分比较简单,一行命令就妥了:

# -t 表示 duration
ffmpeg -ss 01:19:27 -i input.mp4 -t 00:00:30 -c copy output.mp4

看起来可行,但还挺不方便的,总不能每次要生成视频都要打开终端吧,要找到生成的视频并浏览也不够便捷,如果还要加上「生词本」的功能,同时查看该单词的具体解释又该怎么办呢?难道要写一个桌面端应用 🤔

我甚至想到用 Next.js 来开发一个本地 web 服务了,感觉还是太麻烦。那有没有能实现这些功能、足够好用,同时开发成本也低的解决方案呢?这时我想到了 Raycast 这个平时一直在用的 Launcher。

浏览了下文档后,发现可以很好地满足需求。

第四步:接入 Raycast(生成单词视频)

简单介绍下 Raycast,它是一个 Mac 下的启动器,跟 Alfred 类似,但 UI 和 UE 比 Alfred 更好,还免费,支持的 Extension 也不少。

这里我们会用到一个脚本用来生成单词视频,一个 Extension 用来浏览单词对应的视频和解释,同时支持添加到单词本(Add to New)。

创建脚本的过程很简单,启动 Raycast 后,输入 Create Script Command,输入必要的信息,就会生成一个 .sh 结尾的文件(需要先在 Setting 的 Extension 页,Add Script Directory),把生成视频的脚本复制上去就行了。效果如下:

第五步:接入 Raycast(浏览单词)

Raycast 提供了方便的 API(React)来搭建界面和交互。得益于良好的设计,这些 API 使用起来非常舒服和直观。同时因为是运行在 Node 环境,所以可以访问本地文件、执行脚本等。

代码也不过百来行:

import { ActionPanel, List, Action, Icon } from '@raycast/api';
import fs from 'fs';
import { execSync } from 'child_process';
import { useState } from 'react';

const allWordsDir = '/Users/limboy/Dropbox/Videos/Snippets/';
const newWordsDir = '/Users/limboy/Dropbox/English/Snippets/Memorizing/';

const allWords = () => {
  return fs.readdirSync(allWordsDir).filter((item) => item[0] !== '.');
};

const newWords = () => {
  return fs.readdirSync(newWordsDir).filter((item) => item[0] !== '.');
};

const allSnippets = (word: string) => {
  const dir = allWordsDir + '/' + word;
  return fs.readdirSync(dir);
};

const toggleNew = (word: string, toNew: boolean) => {
  if (toNew) {
    execSync(`ln -s ${allWordsDir}${word} ${newWordsDir}`);
  } else {
    execSync(`rm ${newWordsDir}${word}`);
  }
};

export default function Command() {
  return (
    <List>
      <List.Item
        title="All Words"
        icon={Icon.Text}
        actions={
          <ActionPanel>
            <Action.Push
              title="All Words"
              target={<WordsList isAll={true} />}
            />
          </ActionPanel>
        }
      />
      <List.Item
        title="New Words"
        icon={Icon.Star}
        actions={
          <ActionPanel>
            <Action.Push
              title="All Words"
              target={<WordsList isAll={false} />}
            />
          </ActionPanel>
        }
      />
    </List>
  );
}

function WordsList({ isAll }: { isAll: boolean }) {
  const wordsFunction = isAll ? allWords : newWords;
  const [words, setWords] = useState(wordsFunction());
  return (
    <List>
      {words.map((word, i) => {
        const dictPath = 'dict://' + word;
        return (
          <List.Item
            key={i}
            icon="list-icon.png"
            title={word}
            actions={
              <ActionPanel>
                <Action.Push
                  icon={Icon.Video}
                  title="Show Video Snippets"
                  target={<Snippets word={word} />}
                />
                <Action.Open title="Show in Dict" target={dictPath} />
                <Action
                  icon={Icon.Circle}
                  title={isAll ? 'Add to New' : 'Remove from New'}
                  onAction={() => {
                    toggleNew(word, isAll);
                    setWords(wordsFunction());
                  }}
                />
              </ActionPanel>
            }
          />
        );
      })}
    </List>
  );
}

function Snippets({ word }: { word: string }) {
  return (
    <List>
      {allSnippets(word).map((snippet, i) => {
        const videoPath = allWordsDir + '/' + word + '/' + snippet;
        return (
          <List.Item
            icon={Icon.Video}
            key={i}
            title={snippet}
            actions={
              <ActionPanel>
                <Action.Open target={videoPath} title="Open Video" />
              </ActionPanel>
            }
          />
        );
      })}
    </List>
  );
}

通过这种方式,不仅让影视文件可以被再次唤醒,同时也有助于单词的记忆,还挺方便的。接下来就看能背多少个单词了。