Golang調(diào)用FFmpeg實(shí)現(xiàn)視頻截圖,裁剪與水印添加功能
1.視頻處理參數(shù)
package request
import (
"mime/multipart"
)
// VideoSnapshot 視頻截圖
type VideoSnapshot struct {
Video *multipart.FileHeader `form:"video"` // 視頻文件
Second int64 `form:"second"` // 截圖間隔秒
}
// VideoCut 視頻剪輯
type VideoCut struct {
Video *multipart.FileHeader `form:"video"` // 視頻文件
Start int64 `form:"start"` // 開始時(shí)間(秒)
Duration int64 `form:"duration"` // 截止時(shí)間(秒)
}
// VideoWatermark 視頻水印
type VideoWatermark struct {
Video *multipart.FileHeader `form:"video"` // 視頻文件
Watermark *multipart.FileHeader `form:"watermark"` // 水印文件,必須是png
}
2.視頻處理接口及實(shí)現(xiàn)
package service
import (
"context"
"ry-go/common/request"
"github.com/labstack/echo/v4"
)
// VideoProcessService 視頻處理接口
type VideoProcessService interface {
Snapshot(e context.Context, param *request.VideoSnapshot) (string, error)
Cut(e context.Context, param *request.VideoCut) (string, error)
Watermark(e context.Context, param *request.VideoWatermark) (string, error)
}package serviceImpl
import (
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"ry-go/common/request"
"ry-go/utils"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog"
)
type VideoProcessServiceImpl struct {
}
func NewVideoProcessServiceImpl() *VideoProcessServiceImpl {
return &VideoProcessServiceImpl{}
}
// saveUploadedFile 保存上傳的文件到臨時(shí)位置并返回路徑
func saveUploadedFile(fileHeader *multipart.FileHeader) (string, error) {
logger := zerolog.DefaultContextLogger
src, err := fileHeader.Open()
if err != nil {
logger.Error().Err(err).Msg("打開上傳文件失敗")
return "", err
}
defer src.Close()
tmpFile, err := os.CreateTemp("", "upload_*"+filepath.Ext(fileHeader.Filename))
if err != nil {
logger.Error().Err(err).Msgf("創(chuàng)建臨時(shí)文件==%v失敗", tmpFile)
return "", err
}
defer tmpFile.Close()
_, err = io.Copy(tmpFile, src)
if err != nil {
os.Remove(tmpFile.Name())
return "", err
}
return tmpFile.Name(), nil
}
// getVideoDuration 返回視頻時(shí)長(zhǎng)(秒)
func getVideoDuration(ctx context.Context, videoPath string) (float64, error) {
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
videoPath,
)
output, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf("ffprobe failed: %w", err)
}
durationStr := strings.TrimSpace(string(output))
if durationStr == "N/A" {
return 0, fmt.Errorf("video duration is unavailable")
}
duration, err := strconv.ParseFloat(durationStr, 64)
if err != nil {
return 0, fmt.Errorf("invalid duration: %s", durationStr)
}
return duration, nil
}
func getVideoResolution(ctx context.Context, videoPath string) (width, height int, err error) {
cmd := exec.CommandContext(
ctx,
"ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=s=,:p=0",
videoPath,
)
out, err := cmd.Output()
if err != nil {
return 0, 0, fmt.Errorf("ffprobe failed: %w", err)
}
line := strings.TrimSpace(string(out))
if line == "" {
return 0, 0, fmt.Errorf("empty ffprobe output")
}
parts := strings.Split(line, ",")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid resolution format: %s", line)
}
width, err = strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return 0, 0, fmt.Errorf("invalid width: %w", err)
}
height, err = strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return 0, 0, fmt.Errorf("invalid height: %w", err)
}
return width, height, nil
}
// runFfmpeg 執(zhí)行ffmpeg命令
func runFfmpeg(ctx context.Context, args ...string) error {
logger := zerolog.DefaultContextLogger
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return fmt.Errorf("operation cancelled: %w", ctx.Err())
}
logger.Error().Err(err).Msg("執(zhí)行ffmpeg失敗")
return fmt.Errorf("ffmpeg failed: %w; stderr: %s", err, stderr.String())
}
return nil
}
func readDirFileCount(dir, suffix string) int64 {
entries, _ := os.ReadDir(dir)
count := int64(0)
for _, e := range entries {
if strings.HasSuffix(e.Name(), suffix) {
count++
}
}
return count
}
func zipDir(zipPath, dir string) error {
zipFile, err := os.Create(zipPath)
if err != nil {
return err
}
defer zipFile.Close()
zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
relPath, err := filepath.Rel(dir, path)
if err != nil {
return err
}
fw, err := zipWriter.Create(relPath)
if err != nil {
return err
}
fs, err := os.Open(path)
if err != nil {
return err
}
defer fs.Close()
_, err = io.Copy(fw, fs)
return err
})
}
func (s *VideoProcessServiceImpl) Snapshot(ctx context.Context, param *request.VideoSnapshot) (string, error) {
logger := zerolog.DefaultContextLogger
if param.Video == nil {
return "", fmt.Errorf("video file is required")
}
if param.Second < 1 {
return "", fmt.Errorf("second must be >= 0")
}
videoPath, err := saveUploadedFile(param.Video)
if err != nil {
logger.Error().Err(err).Msgf("failed to save video %s", videoPath)
return "", fmt.Errorf("failed to save video: %w", err)
}
defer func(name string) {
if err = os.Remove(name); err != nil {
logger.Error().Err(err).Msgf("failed to delete video %s", videoPath)
}
logger.Debug().Msgf("successful to delete video %s", videoPath)
}(videoPath)
// 創(chuàng)建臨時(shí)目錄存放所有截圖
tempDir, err := os.MkdirTemp("", "snapshots_*")
if err != nil {
return "", fmt.Errorf("failed to create temp dir: %w", err)
}
logger.Debug().Msgf("temp dir === %s", tempDir)
outputPattern := filepath.Join(tempDir, "snapshot_%04d.jpg")
args := []string{
"-i", videoPath,
"-vf", fmt.Sprintf("fps=1/%d", param.Second),
"-q:v", "2",
"-y",
outputPattern,
}
if err = runFfmpeg(ctx, args...); err != nil {
os.RemoveAll(tempDir)
logger.Error().Err(err).Msg("ffmpeg batch snapshot failed")
return "", fmt.Errorf("batch snapshot failed: %w", err)
}
// 掃描目錄
count := readDirFileCount(tempDir, ".jpg")
if err = zipDir(zipPath, tempDir); err != nil {
os.RemoveAll(tempDir)
return "", fmt.Errorf("failed to create zip: %w", err)
}
// 清理原始圖片(保留 zip)
os.RemoveAll(tempDir)
logger.Debug().Msgf("successful to delete video temp %s", tempDir)
logger.Debug().Msgf("Snapshot saved to path %s,count===%d", zipPath, count)
return zipPath, nil
}
func (s *VideoProcessServiceImpl) Cut(ctx context.Context, param *request.VideoCut) (string, error) {
logger := zerolog.DefaultContextLogger
if param.Video == nil {
logger.Error().Msg("video file can not null")
return "", fmt.Errorf("video file is required")
}
videoPath, err := saveUploadedFile(param.Video)
if err != nil {
logger.Error().Err(err).Msgf("failed to save video %s", videoPath)
return "", fmt.Errorf("failed to save video: %w", err)
}
defer os.Remove(videoPath)
// 記錄開始時(shí)間
startCut := time.Now()
// 獲取視頻總時(shí)長(zhǎng)
videoDuration, err := getVideoDuration(ctx, videoPath)
if err != nil {
return "", err
}
// 起始時(shí)間(秒)
start := float64(param.Start)
// 期望截取時(shí)長(zhǎng)(秒)
wantDuration := float64(param.Duration)
// 剩余可截取的最大時(shí)長(zhǎng)
maxDuration := videoDuration - start
if maxDuration <= 0 {
return "", fmt.Errorf("start time exceeds video duration")
}
// 實(shí)際截取時(shí)長(zhǎng)
actualDuration := wantDuration
if wantDuration > maxDuration {
actualDuration = maxDuration
}
startStr := fmt.Sprintf("%.3f", start)
durationStr := fmt.Sprintf("%.3f", actualDuration)
outputPath := filepath.Join(os.TempDir(), fmt.Sprintf("cut_%d.mp4", time.Now().Unix()))
args := []string{
"-i", videoPath,
"-ss", startStr,
"-to", durationStr,
"-c", "copy",
"-y",
outputPath,
}
err = runFfmpeg(ctx, args...)
if err != nil {
return "", err
}
elapsed := time.Since(startCut)
logger.Debug().Msgf("視頻截取耗時(shí)===%f秒", elapsed.Seconds())
logger.Debug().Msgf("Cut video saved to: %s", outputPath)
return outputPath, nil
}
func (s *VideoProcessServiceImpl) Watermark(ctx context.Context, param *request.VideoWatermark) (string, error) {
logger := zerolog.DefaultContextLogger
if len(param.Video.Header) == 0 {
return "", fmt.Errorf("missing video file")
}
if len(param.Watermark.Header) == 0 {
return "", fmt.Errorf("missing watermark image")
}
videoPath, err := saveUploadedFile(param.Video)
if err != nil {
logger.Error().Err(err).Msgf("failed to save video %s", videoPath)
return "", fmt.Errorf("failed to save video: %w", err)
}
defer os.Remove(videoPath)
watermarkPath, err := saveUploadedFile(param.Watermark)
if err != nil {
logger.Error().Err(err).Msgf("failed to save watermark %s", watermarkPath)
return "", fmt.Errorf("failed to save watermark: %w", err)
}
defer os.Remove(watermarkPath)
outputPath := filepath.Join(os.TempDir(), fmt.Sprintf("watermarked_%d.mp4", time.Now().Unix()))
// 記錄開始時(shí)間
start := time.Now()
// 獲取視頻分辨率
videoWidth, _, err := getVideoResolution(ctx, videoPath)
if err != nil {
return "", err
}
// 計(jì)算水印目標(biāo)寬度(15%,最小 100,最大 400)
wmTargetWidth := int(float64(videoWidth) * 0.15)
if wmTargetWidth < 100 {
wmTargetWidth = 100
}
if wmTargetWidth > 400 {
wmTargetWidth = 400
}
filter := fmt.Sprintf(
"[1:v]scale=%d:-1[wm];[0:v][wm]overlay=main_w-overlay_w-10:10",
wmTargetWidth,
)
err = runFfmpeg(
ctx,
"-i", videoPath,
"-i", watermarkPath,
"-filter_complex", filter,
"-c:v", "libx264",
"-c:a", "aac",
"-strict", "-2",
"-y",
outputPath,
)
if err != nil {
return "", err
}
elapsed := time.Since(start)
logger.Debug().Msgf("視頻水印耗時(shí)===%f秒", elapsed.Seconds())
logger.Debug().Msgf("Watermarked video saved to: %s", outputPath)
return outputPath, nil
}
3.視頻處理控制器
package controller
import (
"fmt"
"net/http"
"net/url"
"os"
"ry-go/business/service"
"ry-go/business/service/serviceImpl"
"ry-go/common/request"
"ry-go/common/response"
"ry-go/utils"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog"
"github.com/spf13/cast"
)
type VideoProcessController struct {
Service service.VideoProcessService
}
// NewVideoProcessController 控制器初始化
func NewVideoProcessController(s *serviceImpl.VideoProcessServiceImpl) *VideoProcessController {
return &VideoProcessController{
Service: s,
}
}
func GetEchoForm(c echo.Context, maxMemory int64) (*multipart.Form, error) {
// 解析表單,設(shè)置表單內(nèi)存緩存大小
if err := c.Request().ParseMultipartForm(maxMemory); err != nil {
return nil, err
}
return c.Request().MultipartForm, nil
}
// HandlerSnapshot 視頻截圖
func (c *VideoProcessController) HandlerSnapshot(e echo.Context) error {
logger := zerolog.DefaultContextLogger
form, err := GetEchoForm(e, 32<<20)
if err != nil {
response.NewRespCodeErr(e, http.StatusInternalServerError, err)
return err
}
files := form.File["video"]
if len(files) == 0 {
response.NewRespCodeMsg(e, http.StatusBadRequest, "video file is required")
return err
}
var second int64
if vals := form.Value["start"]; len(vals) > 0 {
second = cast.ToInt64(vals[0])
if second < 1 {
response.NewRespCodeMsg(e, http.StatusBadRequest, "start must be >= 1")
return err
}
} else {
response.NewRespCodeMsg(e, http.StatusBadRequest, "start is required")
return err
}
zipPath, err := c.Service.Snapshot(e.Request().Context(), &request.VideoSnapshot{
Video: files[0],
Second: second,
})
if err != nil {
return err
}
replace := strings.Replace(time.Now().Local().Format("20060102150405.000"), ".", "", 1)
uniqueFileName := url.PathEscape(fmt.Sprintf("snapshot_%s_%s.zip", utils.ShortUUID(), replace))
e.Response().Header().Set(echo.HeaderContentDisposition,
fmt.Sprintf("attachment; filename=\"%s\"; filename*=UTF-8''%s", uniqueFileName, uniqueFileName))
if err = e.Attachment(zipPath, uniqueFileName); err != nil {
// 發(fā)送失敗記錄日志
fmt.Printf("Failed to send file %s: %v\n", zipPath, err)
os.Remove(zipPath)
response.NewRespCodeMsg(e, http.StatusInternalServerError, "failed to send result")
return err
}
go func(path string) {
time.Sleep(5 * time.Second)
os.Remove(path)
logger.Debug().Msgf("刪除了zip臨時(shí)文件路徑===%s", zipPath)
}(zipPath)
return nil
}
// HandlerCut 視頻截取
func (c *VideoProcessController) HandlerCut(e echo.Context) error {
// 解析表單,設(shè)置緩存為 32MB
form, err := GetEchoForm(e, 32<<20)
if err != nil {
response.NewRespCodeErr(e, http.StatusInternalServerError, err)
return err
}
files := form.File["video"]
if len(files) == 0 {
response.NewRespCodeMsg(e, http.StatusBadRequest, "video file is required")
return err
}
var start, duration int64
if vals := form.Value["start"]; len(vals) > 0 {
start = cast.ToInt64(vals[0])
} else {
response.NewRespCodeMsg(e, http.StatusBadRequest, "start is required")
return err
}
if vals := form.Value["duration"]; len(vals) > 0 {
duration = cast.ToInt64(vals[0])
if duration <= 0 {
response.NewRespCodeMsg(e, http.StatusBadRequest, "duration must be > 0")
return err
}
if duration > 0 && duration <= start {
response.NewRespCodeMsg(e, http.StatusBadRequest, "duration must be > 0 and muse be > start")
return err
}
} else {
response.NewRespCodeMsg(e, http.StatusBadRequest, "duration is required")
return err
}
outputPath, err := c.Service.Cut(e.Request().Context(), &request.VideoCut{
Video: files[0],
Start: start,
Duration: duration,
})
if err != nil {
return err
}
// 生成唯一文件名
replace := strings.Replace(time.Now().Local().Format("20060102150405.000"), ".", "", 1)
uniqueFileName := url.PathEscape(fmt.Sprintf("cut_%s_%s.mp4", utils.ShortUUID(), replace))
e.Response().Header().Set(echo.HeaderContentDisposition,
fmt.Sprintf("attachment; filename=\"%s\"; filename*=UTF-8''%s", uniqueFileName, uniqueFileName))
if err = e.Attachment(outputPath, uniqueFileName); err != nil {
// 發(fā)送失敗記錄日志
fmt.Printf("Failed to send file %s: %v\n", outputPath, err)
os.Remove(outputPath)
response.NewRespCodeMsg(e, http.StatusInternalServerError, "failed to send result")
return err
}
go func(path string) {
time.Sleep(5 * time.Second)
os.Remove(path)
zerolog.DefaultContextLogger.Debug().Msgf("刪除了視頻文件路徑===%s", outputPath)
}(outputPath)
return nil
}
// HandlerWatermark 視頻添加水印
func (c *VideoProcessController) HandlerWatermark(e echo.Context) error {
// 解析表單,設(shè)置緩存為 32MB
form, err := GetEchoForm(e, 32<<20)
if err != nil {
response.NewRespCodeErr(e, http.StatusInternalServerError, err)
return err
}
outputPath, err := c.Service.Watermark(e.Request().Context(), &request.VideoWatermark{
Video: form.File["video"][0],
Watermark: form.File["watermark"][0],
})
if err != nil {
response.NewRespCodeErr(e, http.StatusInternalServerError, err)
return err
}
replace := strings.Replace(time.Now().Local().Format("20060102150405.000"), ".", "", 1)
uniqueFileName := url.PathEscape(fmt.Sprintf("%s_%s.mp4", utils.ShortUUID(), replace))
e.Response().Header().Set(echo.HeaderContentDisposition,
fmt.Sprintf("attachment; filename=\"%s\"; filename*=UTF-8''%s", uniqueFileName, uniqueFileName))
if err = e.Attachment(outputPath, uniqueFileName); err != nil {
// 發(fā)送失敗記錄日志
fmt.Printf("Failed to send file %s: %v\n", outputPath, err)
os.Remove(outputPath)
response.NewRespCodeMsg(e, http.StatusInternalServerError, "failed to send result")
return err
}
go func(path string) {
time.Sleep(10 * time.Second)
os.Remove(path)
zerolog.DefaultContextLogger.Debug().Msgf("刪除了視頻文件路徑===%s", outputPath)
}(outputPath)
return nil
}
4.視頻處理路由配置
package routers
import (
"ry-go/business/controller"
"github.com/labstack/echo/v4"
)
func VideoProcessRouterInit(group *echo.Group, videoController *controller.VideoProcessController) {
routerGroup := group.Group("/video")
routerGroup.POST("/snapshot", videoController.HandlerSnapshot)
routerGroup.POST("/cut", videoController.HandlerCut)
routerGroup.POST("/watermark", videoController.HandlerWatermark)
}
這里的路由使用了echo,具體用法這里不做詳細(xì)說(shuō)明了,具體內(nèi)容請(qǐng)自行查看官網(wǎng)文檔
5.其他說(shuō)明
注意:上述代碼依賴 ffmpeg 工具,請(qǐng)先確保它已在您的系統(tǒng)中安裝并配置好環(huán)境變量。如果您尚未安裝,可參考官方文檔或相關(guān)教程完成安裝,本文不再詳細(xì)介紹。詳情參見ffmpeg官網(wǎng)
上述代碼均使用apifox測(cè)試,功能正常
到此這篇關(guān)于Golang調(diào)用FFmpeg實(shí)現(xiàn)視頻截圖,裁剪與水印添加功能的文章就介紹到這了,更多相關(guān)Go FFmpeg視頻處理內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Golang利用compress/flate包來(lái)壓縮和解壓數(shù)據(jù)
在處理需要高效存儲(chǔ)和快速傳輸?shù)臄?shù)據(jù)時(shí),數(shù)據(jù)壓縮成為了一項(xiàng)不可或缺的技術(shù),Go語(yǔ)言的compress/flate包為我們提供了對(duì)DEFLATE壓縮格式的原生支持,本文將深入探討compress/flate包的使用方法,揭示如何利用它來(lái)壓縮和解壓數(shù)據(jù),并提供實(shí)際的代碼示例,需要的朋友可以參考下2024-08-08
Golang JSON的進(jìn)階用法實(shí)例講解
這篇文章主要給大家介紹了關(guān)于Golang JSON進(jìn)階用法的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家學(xué)習(xí)或者使用golang具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2018-09-09
golang 生成二維碼海報(bào)的實(shí)現(xiàn)代碼
這篇文章主要介紹了golang 生成二維碼海報(bào)的實(shí)現(xiàn)代碼,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2020-02-02
go?singleflight緩存雪崩源碼分析與應(yīng)用
這篇文章主要為大家介紹了go?singleflight緩存雪崩源碼分析與應(yīng)用示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-09-09

