使用gin和ffmpeg制作一个短视频生成器(1)

3,174 阅读12分钟

实现功能:

  • ffmpeg合成三种资源,配音,配乐,视频
  • 阿里云文字转语音
  • 七牛云文件上传
  • 多图+配音+配乐(开发中)

本篇文章主要讲解基于Go语言的web框架-Gin的基本架构,ffmpeg是一个开源的多媒体处理工具,下篇文章介绍ffmpeg主要功能,最后会把代码开源出来,自己也是一个go语言新手,本篇文章会有很多的代码,代码里面会有很多的注释,如果错误敬请指出。

项目架构:

2021-05-29 17-25-40 的屏幕截图.png

接下来按照项目的目录从上到下的顺序介绍一下这些文件是干嘛的

config:

主要实现对.env配置文件的读取供全局代码使用,使用.env可以在CI/CD(项目持续交付持续部署,如果文章反响好的话后面会尝试使用docker部署项目)的时候可以更方便的区分运行环境的差异,减少工作量,Config结构体定义的配置项主要是项目的运行地址端口,项目名字还有一些阿里云,七牛云的秘钥对,这里需要注意的是.env文件不能有空行

2021-05-29 21-35-47 的屏幕截图.png

package config

import (
	"bufio"
	"log"
	"os"
	"reflect"
	"strconv"
	"strings"
)

type Config struct {
	APP_ENV             string
	APP_HOST            string
	APP_PORT            string
	APP_NAME            string
	QINIU_AccessKey     string
	QINIU_SecretKey     string
	QINIU_BUCKET        string
	ALI_AccessKeyID     string
	ALI_AccessKeySecret string
	ALI_AppKey          string
}

func Get() (Config, error) {
	//创建一个空的结构体,将本地文件读取的信息放入
	c := &Config{}
	//创建一个结构体变量的反射
	cr := reflect.ValueOf(c).Elem()
	//打开文件io流
	f, err := os.Open(".env")
	if err != nil {
		return *c, err
	}
	defer func() { //关闭文件后执行
		if err = f.Close(); err != nil {
			log.Fatal(err)
		}
	}()
	//我们要逐行读取文件内容
	s := bufio.NewScanner(f)
	for s.Scan() {
		//以=分割,前面为key,后面为value
		var str = s.Text()
		var index = strings.Index(str, "=")
		var key = str[0:index]
		var value = str[index+1:]
		//因为Port是int,所以我们这里要将截取的string强转成int
		if strings.Contains(key, "Port") {
			var i, err = strconv.Atoi(value)
			if err != nil {
				panic(err)
			}
			//通过反射将字段设置进去
			cr.FieldByName(key).Set(reflect.ValueOf(i))
		} else {
			//通过反射将字段设置进去
			cr.FieldByName(key).Set(reflect.ValueOf(value))
		}

	}
	err = s.Err()
	if err != nil {
		return *c, err
	}
	//返回Config结构体变量
	return *c, nil

}

controller

该包下面只有一个控制器,因为项目很简单,主要功能是ffmpeg的功能。一种功能的方法几个就写在一个控制器里面,控制器会获取接口参数,调用验证器验证,然后调用service方法返回给接口调用者,主要功能不写在这里,主要是让项目更加有条理清晰。

package controller

import (
	"gin-ffmpeg/global"
	"gin-ffmpeg/request"
	"gin-ffmpeg/service"

	"net/http"

	"github.com/gin-gonic/gin"
)

//音视频合成
func SynthesisAudioAndVideo(c *gin.Context) {
	var json request.SynthesisAudioAndVideoRequest
	if err := c.ShouldBindJSON(&json); err != nil {
		println(err.Error())
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	result := global.NewResult(c)
	data, err := service.SynthesisAudioAndVideo(json)
	if err != nil {
		result.Error(5201, err.Error(), data)
		return
	}
	result.Success(data)
}

//阿里云文字转语音
func DubbingBuild(c *gin.Context) {
	var json request.DubbingBuildRequest
	if err := c.ShouldBindJSON(&json); err != nil {
		println(err.Error())
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	result := global.NewResult(c)
	data, err := service.DubbingBuild(json)
	if err != nil {
		result.Error(5201, err.Error(), data)
		return
	}
	result.Success(data)
}

global

自定义错误报错信息的结构体和方法,把框架里的报错统一收集起来返回给调用者。在控制器理由调用它的地方,好处就是让调用者更好处理数据,不管框架处理成功和失败都是同一样的数据接口返回给调用者。调用者不用写过多的代码处理数据

package global

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

//Result 暴露
type Result struct {
	Ctx *gin.Context
}
 
//ResultCont 返回的结果:
type ResultCont struct {
	Code	int  `json:"code"`     //提示代码
	Msg string  `json:"msg"`       //提示信息
	Data interface{} `json:"data"` //数据
}
//NewResult 创建新返回对象
func NewResult(ctx *gin.Context) *Result {
	return &Result{Ctx: ctx}
}
 
//Success 成功
func (r *Result) Success(data interface{}) {
	if (data == nil) {
		data = gin.H{}
	}
	res := ResultCont{}
	res.Code = 200
	res.Data = data
	res.Msg = "成功!"
	r.Ctx.JSON(http.StatusOK,res)
}
 
//Error 出错
func (r *Result)Error(code int,msg string,data interface{}) {
	if (data == nil) {
		data = gin.H{}
	}
	res := ResultCont{}
	res.Code = code
	res.Msg = msg
	res.Data = data
	r.Ctx.JSON(http.StatusOK,res)
}

middlewares

主要处理框架跨域的功能,允许所有域的请求,这里没有做用户鉴权,后面做用户系统可以在这里使用一些token的验证

package middlewares

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func Cors() gin.HandlerFunc {
	return func(c *gin.Context) {
		method := c.Request.Method
		// if origin != "" {
			c.Header("Access-Control-Allow-Origin", "*")  // 可将将 * 替换为指定的域名
			c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, UPDATE")
			c.Header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
			c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Cache-Control, Content-Language, Content-Type")
			c.Header("Access-Control-Allow-Credentials", "true")
		// }
		if method == "OPTIONS" {
			c.AbortWithStatus(http.StatusNoContent)
		}
		c.Next()
	}
}

request

这里就是验证器,就只定义了一些结构体,在控制器里调用,定义了所有参数的名字数据类型和是否是必要的

package request

//音视频生成
type SynthesisAudioAndVideoRequest struct {
	VideoURL      string `form:"video_url" json:"video_url" binding:"required"`
	DubbingURL    string `form:"subbing_url" json:"subbing_url" binding:"required"`
	MusicURL      string `form:"music_url" json:"music_url" binding:"required"`
	VideoSecond   int    `form:"video_second" json:"video_second" binding:"required"`
	DubbingSecond int    `form:"dubbing_second" json:"dubbing_second" binding:"required"`
}

//阿里云文字转语音,接收文字
type DubbingBuildRequest struct {
	Text string `form:"text" json:"text" binding:"required"`
}

route

这里定义了一个路由,设置了一些异常处理的方法,处理调用者传了一些不存在的方法和路由路径以及框架的内部报错, 还可以在这个文件给不同的路由路径分配不同的中间件做不同验证,一般框架的路由指定了接口调用的controller方法,因为为了防止项目接口过多,假如把路由,控制器,验证器写在一起,文件就会非常的长,不利于开发优化,这样把功能一样的代码写在一起,开发起来就会更方便有条理

package router

import (
	"gin-ffmpeg/config"
	"gin-ffmpeg/controller"
	"gin-ffmpeg/global"
	"gin-ffmpeg/middlewares"
	"log"
	"net/http"
	"runtime/debug"

	"github.com/gin-gonic/gin"
)

//Router 路由方法
func Router() *gin.Engine {
	router := gin.Default()
	//处理异常
	router.NoRoute(HandleNotFound)
	router.NoMethod(HandleNotFound)
	router.Use(Recover)
	router.Use(middlewares.Cors())
	router.GET("/", func(c *gin.Context) {
		env, _ := config.Get()
		c.JSON(http.StatusOK, "hello "+env.APP_NAME)
		return
	})
	//dy group
	dy := router.Group("/ffmpeg")
	{
		// 路径映射
		dy.POST("/SynthesisAudioAndVideo", controller.SynthesisAudioAndVideo)
                dy.POST("/DubbingBuild", controller.DubbingBuild)
	}

	return router
}

//HandleNotFound 404
func HandleNotFound(c *gin.Context) {
	global.NewResult(c).Error(404, "资源未找到", nil)
	return
}

//Recover 500
func Recover(c *gin.Context) {
	defer func() {
		if r := recover(); r != nil {
			//打印错误堆栈信息
			log.Printf("panic: %v\n", r)
			debug.PrintStack()
			global.NewResult(c).Error(500, "服务器内部错误", nil)
		}
	}()
	//继续后续接口调用
	c.Next()
}

service

这里主要实现ffmpeg主要核心功能,使用到了一些公共方法,保存传入的资源,通过七牛云来上传的资源,还有go调用系统终端命令来调用ffmpeg的功能

package service

import (
	"bytes"
	"errors"
	"fmt"
	"gin-ffmpeg/request"
	"gin-ffmpeg/util"
	"log"
	"os/exec"
	"strconv"
	"strings"
	"time"
)

func SynthesisAudioAndVideo(json request.SynthesisAudioAndVideoRequest) (data interface{}, err error) {
	videoPath, err := util.DownloadVideoOrAudio(json.VideoURL, "video")
	if err != nil {
		return err, errors.New("视频保存失败")
	}
	DubbingPath, err := util.DownloadVideoOrAudio(json.DubbingURL, "voice")
	if err != nil {
		return err, errors.New("配音保存失败")
	}
	MusicPath, err := util.DownloadVideoOrAudio(json.MusicURL, "voice")
	if err != nil {
		return err, errors.New("背景音保存失败")
	}
	VideoSecond := json.VideoSecond
	DubbingSecond := json.DubbingSecond
	blankSecond := strconv.Itoa((VideoSecond - DubbingSecond) * 1000 / 2)
	if (VideoSecond - DubbingSecond) < 0 {
		blankSecond = "100"
	}
	endSecond := strconv.Itoa(VideoSecond)
	imgFilePath := "video/" + time.Now().Format("20060102") + "/"
	util.CreateDir("uploads/" + imgFilePath)
	imgFileName := time.Now().Format("20060102150405") + util.RandString(10) + ".mp4"
	cmdArguments := []string{"-i", videoPath, "-i", DubbingPath, "-i", MusicPath, "-ss", "0", "-t", endSecond, "-filter_complex", "[1]adelay=" + blankSecond + "|" + blankSecond + "|[a];[a][2]amix=inputs=2[a]", "-map", "0:v", "-map", "[a]", "-c:v", "copy", "uploads/" + imgFilePath + imgFileName}
	// ffmpeg -i 1.mp4 - 3v.m4a -i 4v.m4a -filter_complex "[1][2]amix=inputs=2[a]" -map 0:v -map "[a]" -c:v copy Output.MP4
	cmd := exec.Command("ffmpeg", cmdArguments...)

	fmt.Println(cmd)
	var out bytes.Buffer
	var stderr bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &stderr
	err = cmd.Run()
	if err != nil {
		fmt.Println(fmt.Sprint(err) + ": " + stderr.String())
		return err, errors.New("视频合成失败")
	}
	resultFilePath := "uploads/" + imgFilePath + imgFileName
	if imgFileName, err = util.Upload("tmp_"+imgFileName, resultFilePath); err != nil { // 通过h.size 即可获得文件大小
		log.Printf("controllers:work  ----》 err:%v", err)
		return err, errors.New("七牛云上传失败")
	}

	return "http://qtmap4q65.hn-bkt.clouddn.com/" + imgFileName, nil
}

upload

存放一些图片,配音,视频资源,ffmpeg输入资源必须是本地的资源,所以要保存到这个文件夹内

util

公共方法,主要是生成随机字符串,创建文件夹,判断文件是否存在,下载资源,上传文件到七牛云,阿里云生成配音,获取数组随机多个数据,计算MP3时长等方法

package util

import (
	"bytes"
	"context"
	"fmt"
	"gin-ffmpeg/config"
	"io"
	"io/ioutil"
	"math"
	"math/rand"
	"net/http"
	"net/url"
	"os"
	"path"
	"strconv"
	"strings"
	"time"

	"github.com/aliyun/alibaba-cloud-sdk-go/sdk"
	"github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests"
	"github.com/qiniu/go-sdk/v7/auth"
	"github.com/qiniu/go-sdk/v7/storage"
	"github.com/tcolgate/mp3"
	"github.com/tidwall/gjson"
)

// RandString 生成随机字符串
func RandString(l int) string {
	str := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
	bytes := []byte(str)
	result := []byte{}
	r := rand.New(rand.NewSource(time.Now().UnixNano()))
	for i := 0; i < l; i++ {
		result = append(result, bytes[r.Intn(len(bytes))])
	}
	return string(result)
}

//CreateDir 创建文件夹
func CreateDir(path string) {
	_, err := os.Stat(path)
	if os.IsNotExist(err) {
		os.Mkdir(path, os.ModePerm)
	}
}

//PathExists 判断文件是否存在
func PathExists(path string) bool {
	_, err := os.Stat(path)
	if err == nil {
		return true
	}
	if os.IsNotExist(err) {
		return false
	}
	return true
}

//下载视频或者音频
func DownloadVideoOrAudio(ResourcesURL string, SavePath string) (string, error) {

	if find := strings.Contains(ResourcesURL, "local"); find {
		//该资源资源连接为本地文件
		fileNameTotalSplit := strings.SplitAfterN(ResourcesURL, "/", 4) //斜杠切割字符串 ,4为切割前3个斜杠,分成四个部分,返回slice,例子切割结果:[https:/, /,***********-shenzhen.aliyuncs.com/,voice/data/202105130005542EaJbdY7aH.wav]
		fileNameTotal := fileNameTotalSplit[len(fileNameTotalSplit)-1]  //取slice最后一个元素,去掉域名部分
		return "uploads/" + fileNameTotal, nil                          //返回文件路径和文件名
	}

	ext := path.Ext(ResourcesURL) //获取资源的后缀
	filePath := "uploads/" + SavePath + "/" + time.Now().Format("20060102") + "/"
	CreateDir(filePath)
	fileNameStr := time.Now().Format("20060102150405") + RandString(10)
	fileName := fileNameStr + ext
	localFile := filePath + fileName //生成文件夹和文件名
	//通过http请求获取图片的流文件
	resp, err := http.Get(ResourcesURL)
	if err != nil {
		return "资源请求失败", err
	}
	body, err := ioutil.ReadAll(resp.Body) //获取文件流
	if err != nil {
		return "资源数据流读取失败", err
	}
	out, err := os.Create(localFile) //创建文件
	if err != nil {
		return "保存资源文件创建失败", err
	}
	io.Copy(out, bytes.NewReader(body)) //写入文件

	return filePath + fileName, nil //返回文件路径和文件名
}

func Upload(filename string, filepath string) (string, error) {
	env, _ := config.Get()
	cfg := storage.Config{}

	// 是否使用https域名
	cfg.UseHTTPS = false
	// 上传是否使用CDN上传加速
	cfg.UseCdnDomains = false
	bucket := env.QINIU_BUCKET
	putPolicy := storage.PutPolicy{
		Scope: bucket,
	}

	mac := auth.New(env.QINIU_AccessKey, env.QINIU_SecretKey)
	upToken := putPolicy.UploadToken(mac)

	formUploader := storage.NewFormUploader(&cfg)

	ret := storage.PutRet{}
	putExtra := storage.PutExtra{}
	//putExtra.NoCrc32Check = true
	err := formUploader.PutFile(context.Background(), &ret, upToken, filename, filepath, &putExtra)
	if err != nil {
		fmt.Println(err)
		return "上传失败", err
	}

	return ret.Key, nil
}

// 阿里云文字转语音

func Voicesynthesis(text string) (string, error) {
	format := "mp3" // 音频编码格式,支持pcm/wav/mp3格式。默认值:pcm。
	voice := "siqi" //发音人,默认值:xiaoyun。更多发音人请参见接口说明。
	env, _ := config.Get()
	token, err := AliToken()
	if err != nil {
		return "token获取失败", err
	}
	geturl := "https://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/tts"
	geturl = geturl + "?appkey=" + env.ALI_AppKey
	geturl = geturl + "&token=" + token
	geturl = geturl + "&text=" + url.QueryEscape(text) //处理中文编码
	geturl = geturl + "&format=" + format
	geturl = geturl + "&voice=" + voice
	print(geturl)
	// 超时时间:5秒
	client := &http.Client{Timeout: 5 * time.Second}
	resp, err := client.Get(geturl)
	if err != nil {
		return "配音生成超时", err
	}
	defer resp.Body.Close()
	data, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "配音文件流读取报错", err
	}
	filePath := "uploads/dubbing/" + time.Now().Format("20060102") + "/"
	CreateDir(filePath)
	fileNameStr := time.Now().Format("20060102150405") + RandString(10)
	fileName := fileNameStr + "." + format
	ioutil.WriteFile(filePath+fileName, data, 0644)
	return filePath + fileName, nil //返回文件路径和文件名
}

//阿里云token生成,这里测试的话,token直接请求不缓存,上线的话用redi缓存起来
func AliToken() (string, error) {
	env, _ := config.Get()
	client, err := sdk.NewClientWithAccessKey("cn-shanghai", env.ALI_AccessKeyID, env.ALI_AccessKeySecret)
	if err != nil {
		return "阿里云获取token参数出错", err
	}
	request := requests.NewCommonRequest()
	request.Method = "POST"
	request.Domain = "nls-meta.cn-shanghai.aliyuncs.com"
	request.ApiName = "CreateToken"
	request.Version = "2019-02-28"
	response, err := client.ProcessCommonRequest(request)
	if err != nil {
		return "阿里云token生成失败", err
	}
	fmt.Print(response.GetHttpStatus())
	fmt.Print(response.GetHttpContentString())

	json := gjson.Get(response.GetHttpContentString(), "Token.Id")
	fmt.Println(json)
	return json.String(), nil
}

//获取MP3的时长(秒)
func Getmp3len(mp3Path string) (string, error) {
	t := 0.0
	r, err := os.Open(mp3Path)
	if err != nil {
		fmt.Println(err)
		return "mp3路径无效", err
	}
	d := mp3.NewDecoder(r)
	var f mp3.Frame
	skipped := 0
	for {

		if err := d.Decode(&f, &skipped); err != nil {
			if err == io.EOF {
				break
			}
			fmt.Println(err)
			return "mp3文件无效", err
		}
		t = t + f.Duration().Seconds()
	}
	return strconv.Itoa(int(math.Ceil(t))), nil //浮点数向上取整转字符串
}

//,
func GetRandArray(origin []string, count int) []string {
	tmpArray := make([]string, len(origin)) //生成一个临时的切片,数组必须要指定长度,值类型
	copy(tmpArray, origin)                  //把传进来的切片复制到这个切片,切片是属于引用类型
	//一定要seed
	rand.Seed(time.Now().Unix())                     //以当前时间为随机因子
	rand.Shuffle(len(tmpArray), func(i int, j int) { //把临时数组打乱
		tmpArray[i], tmpArray[j] = tmpArray[j], tmpArray[i]
	})

	resultArray := make([]string, 0, count) //生成一个长度为传入个数的结果切片
	for index, value := range tmpArray {    //把临时切片复制到结果切片,达到传入个数退出循环,返回结果
		if index == count {
			break
		}
		resultArray = append(resultArray, value)
	}
	return resultArray
}

main.go

项目的入口函数,从这里启动整个项目,会打印一些项目的基本信息

ffmpeg命令讲解

这里稍微讲一下上面ffmpeg命令基本流程: 2021-05-29 18-17-01 的屏幕截图.png 上图我自己调用的时候的截图,现在讲解一下这条命令的含义:

/usr/bin/ffmpeg -i uploads/video/20210529/20210529171251oASGS9S2l1.mp4 -i uploads/voice/20210529/20210529171251xx0JjoEAfG.mp3 -i uploads/voice/20210529/20210529171251QxskK9YqrB.mp3 -ss 0 -t 13 -filter_complex [1]adelay=0|0|[a];[a][2]amix=inputs=2[a] -map 0:v -map [a] -c:v copy uploads/video/20210529/20210529171251QiZ1AssMUc.mp4

调用ffmpeg首先要配置好ffmpeg的系统环境变量。
** 以下流程解释为个人经验结合文档资料总结理解,正确性准确性存疑~

  • -i是指定ffmpeg的输入资源,需要记住输入资源的顺序,后面要使用过滤器调用这些资源。这条命令总共有三个输入资源,第一个是视频,第二个是配音,第三个是背景音乐。
  • -ss-t 是剪切视频参数,-ss 0指从视频0秒开始剪切,-t 13指从0秒开始数距离13秒结束,13这里是上面的视频时长。
  • -filter_complex是ffmpeg的使用过滤器方法的提示,在后面使用过滤器的时候不允许存在空格。
  • [1]adelay=0|0|[a];[a][2]amix=inputs=2[a]这个方法中间没有任何空格,在windows可以使用单引号,但是linux不行。
  • [1]adelay[1]是过滤器的传入的资源序列,代表配音,adelay为验证器,在ffmpeg官方文档可以查到,延迟音频作用(添加静音),以|分割成三个部分,第一部分和第二部分为左声道右声道,左右不一样就会很奇怪,推荐左右一样,第三个就是过滤器输出序列,记住这个名为[a]的序列。
  • ;分割多个过滤器amix就是第二个过滤器,配音处理完的序列[a]和传入的背景音序列[2]合并成一个音频(不是拼接,是同时播放的合并,播放起点都是从零开始),合成音频结果序列为[2]
  • -map命令是控制输入序列和合并,-0:v表示整个视频流,[a]表示整个音频流,合并整个视频和音频文件
  • copy将合并生成的视频流保存为某个文件。

总结

Gin框架比较简洁,可以使用单文件就可以运行起来,很多新手就会写的很乱,于是乎就总结一些Gin框架的比较正确的结构方式,然后稍微说了一下ffmpeg的命令,有什么建议欢迎指出哦,明天总结继续讲解ffmpeg的高级使用方法,谢谢大家看到最后~