前言
作者这个周末因为脚扭了所以没办法出去玩,只能在家宅着,看书之余发现了知乎的一个新功能,可以把文章转成视频,不需要自己编辑,非常傻瓜和无脑。
作为一个程序员,看到一个东西的第一眼就是想着怎么实现,正好自己也没法出门,所以就花了几个小时的时间实现了一个乞丐版本的文字转视频。下面会介绍一下实现需要解决的几个问题和后续的优化方向。
实现文章转视频需要解决的几个问题
-
文字分割
要生成视频,就必须有语音,为了实现朗读的效果,就不能很机械的一个字一个字的读,也不能一段话连续的读下去没有停顿。所以文字分割的重点是能够按照人类朗读的节奏将文字分割。
-
文字生成图片
文字没有什么太麻烦的地方,只需要生成一张黑色的底图,然后将文字贴上去即可
-
文字转换成语音
文字转换成语音就不需要说了,需要注意的一点是不能够一字一句的读也不能一下子把全部的文字一股脑读出来,需要按照人类的朗读方式发声
-
图片contact成视频
我们知道视频其实就是一张张图片的组成,我们可以将没有声音的视频理解为一个正在播放的PPT,所以在连接图片的时候,需要考虑到朗读的速度,决定每一张图片需要展示多久的时间
-
语音 contact 成音频
这个部分也是没有什么难点,只需要按照顺序将语音连接在一起即可
-
音频和视频合并
音频和视频合并也并不麻烦,只需要一条命令即可搞定
文字分割
首先是文字分割,市面上开源的分词工具都不满足按照语义分割段落,例如结巴分词(可能是我没找到,有了解的同学可以告诉我)。所以为了保持语义和语速,我使用了按照标点符号分割文字的办法.
实现如下,即将标点符号之间的文字作为一句完整的话,
text := `让座,汽车在山路上颠簸,刚上车的老大爷有些站不稳。车子满员,已经无座,有几个多余的乘客站在廊道里忍受着游来荡去地颠簸。穿黄色T恤衫的小伙子骂一句:“他妈的,刚修的水泥路没几年坏成这样,都他妈只知道偷工减料赚钱了”老大爷手紧紧抓着横杠身子游来荡去。天气有些闷热。老人家浑身散发出一股熏人的汗酸臭和泥涩味儿。`
var data []string
d := ""
for _, str :=range strings.Split(text, "\n") {
for _, t := range str {
if !unicode.IsPunct(t) {
d = fmt.Sprintf("%s%s", d, string(t))
} else if unicode.IsSpace(t) {
} else {
if d != "" {
data = append(data, d)
d = ""
}
}
}
}
文字生成图片
文字生成图片没有什么好说的,go本身自带的包就足够了,不过需要注意的一点是,字体需要支持中文,否则会展示成乱码
func(i *Imager) genBaseImage(filename, text string) error {
m := image.NewRGBA(image.Rect(0, 0, 1600, 900))
draw.Draw(m, m.Bounds(), image.Black, image.ZP, draw.Src)
if err := internal.CreateFolderIfNotExists("image"); err != nil {
return err
}
// Read the font data.
fontBytes, err := ioutil.ReadFile("./front/SourceHanSans-Bold.ttf")
if err != nil {
return err
}
f, err := freetype.ParseFont(fontBytes)
if err != nil {
return err
}
c := freetype.NewContext()
c.SetDPI(72)
c.SetFont(f)
c.SetFontSize(defaultSize)
c.SetClip(m.Bounds())
c.SetDst(m)
c.SetSrc(image.White)
pt := freetype.Pt(350, 200+int(c.PointToFixed(defaultSize)>>6))
if _, err := c.DrawString(text, pt); err != nil {
return err
}
fd, err := os.Create(fmt.Sprintf("image/%s.jpeg", filename))
if err != nil {
return err
}
err = jpeg.Encode(fd, m, nil)
if err != nil {
return err
}
return nil
}
文字转换成语音
文字转换语音这一块我试了很读多工具 ffmpeg 、especk、google 翻译,最终还是google 翻译最为方便,ffmpeg 需要安装flite,但是我一直装不上。especk 也是可以的,但是我没用过,不知道效果怎么样,所以最终选择了 google 翻译 的服务,只需要发一个请求即可。
func (speech *Speech) downloadIfNotExists(fileName string, text string) error {
f, err := os.Open(fileName)
if err != nil {
response, err := http.Get(fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&total=1&idx=0&textlen=32&client=tw-ob&q=%s&tl=%s", url.QueryEscape(text), speech.Language))
if err != nil {
return err
}
defer response.Body.Close()
output, err := os.Create(fileName)
if err != nil {
return err
}
_, err = io.Copy(output, response.Body)
return err
}
_ = f.Close()
return nil
}
图片contact 成视频
这个步骤会迎来一个第一个难点,图片生成之后,要如何连接成视频?每张图片要播放多久?这个地方我试了好几次,最终才确定字符数和语速的的关系,大概就是 8个字符作为一秒是比较稳定的朗读速度。
首先是如何连接图片称为视频:
c1 := exec.Command("ffmpeg", "-f", "concat", "-i", "contact_image.txt", "-vsync", "vfr", "-pix_fmt", "yuv420p", "video.mp4")
生成配置文件告知 ffmpeg 按照什么样的播放速度合成视频:
for i, d := range data {
fnImage := fmt.Sprintf("image0%d", i)
fnAudio := fmt.Sprintf("audio0%d", i)
s := Speech{
Folder: "audio",
FileName: fnAudio,
Language: "zh",
}
_ = s.GenerateAudio(d)
img := Imager{}
_ = img.genBaseImage(fnImage, d)
fd_image.Write([]byte(fmt.Sprintf("file 'image/%s.jpeg'\n", fnImage)))
duration := 0
if len(d)/ 3 > 1 {
duration = len(d)/ 8
}
fd_image.Write([]byte(fmt.Sprintf("duration %d\n", duration)))
fd_audio.Write([]byte(fmt.Sprintf("file 'audio/%s.mp3'\n", fnAudio)))
}
语音 contact 成音频
语音连接成音频没有什么难点,一条命令即可搞定
c2 := exec.Command("ffmpeg", "-f", "concat", "-safe", "0", "-i", "contact_audio.txt", "-c", "copy", "output.mp3")
音频和视频合并
这一步也没有难度,只需要一条命令即可
c3 := exec.Command("ffmpeg", "-i", "video.mp4", "-i", "output.mp3", "-c:v", "copy", "-c:a", "aac", "output.mp4")
尾声
到此为止,文章转视频的实现就实现了,是不是很简单, 唯一的依赖,就是需要安装的 ffmpeg 了,相信这个不需要我告诉你怎么安装了。
详细的代码可以参见我的github, GitHub - leoython/text-to-video: 知乎文章转视频的实现(乞丐版)
TODO
在一开始我就说了,这其实是一个乞丐版本,还有很多可以优化的地方。
- 文字的分割,现在是按照标点符号来分割的,如果可以使用机器学习训练的模型按照语义分割,会更加像人类一些。
- 文字转语音,现在的朗读声还是很机械的,如果可以更加像人一些就好了
- 文字转图片,代码中只实现了一个非常简单的版本,如果要工业化,起码需要判断文字的长度自动换行,自动判断字体大小。