实现功能:
- ffmpeg合成三种资源,配音,配乐,视频
- 阿里云文字转语音
- 七牛云文件上传
- 多图+配音+配乐(开发中)
本篇文章主要讲解基于Go语言的web框架-Gin的基本架构,ffmpeg是一个开源的多媒体处理工具,下篇文章介绍ffmpeg主要功能,最后会把代码开源出来,自己也是一个go语言新手,本篇文章会有很多的代码,代码里面会有很多的注释,如果错误敬请指出。
项目架构:
接下来按照项目的目录从上到下的顺序介绍一下这些文件是干嘛的
config:
主要实现对.env配置文件的读取供全局代码使用,使用.env可以在CI/CD(项目持续交付持续部署,如果文章反响好的话后面会尝试使用docker部署项目)的时候可以更方便的区分运行环境的差异,减少工作量,Config结构体定义的配置项主要是项目的运行地址端口,项目名字还有一些阿里云,七牛云的秘钥对,这里需要注意的是.env文件不能有空行
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命令基本流程:
上图我自己调用的时候的截图,现在讲解一下这条命令的含义:
/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的高级使用方法,谢谢大家看到最后~