Golang调用FFmpeg实现视频截图,裁剪与水印添加功能

Written by

in

文章目录
  • 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”` // 开始时间(秒) Duration int64 `form:”duration”` // 截止时间(秒) } // VideoWatermark 视频水印 type VideoWatermark struct { Video *multipart.FileHeader `form:”video”` // 视频文件 Watermark *multipart.FileHeader `form:”watermark”` // 水印文件,必须是png }
  • 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 保存上传的文件到临时位置并返回路径 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(“创建临时文件==%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 返回视频时长(秒) 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 执行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(“执行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) // 创建临时目录存放所有截图 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) // 记录开始时间 startCut := time.Now() // 获取视频总时长 videoDuration, err := getVideoDuration(ctx, videoPath) if err != nil { return “”, err } // 起始时间(秒) start := float64(param.Start) // 期望截取时长(秒) wantDuration := float64(param.Duration) // 剩余可截取的最大时长 maxDuration := videoDuration – start if maxDuration <= 0 { return “”, fmt.Errorf(“start time exceeds video duration”) } // 实际截取时长 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(“视频截取耗时===%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())) // 记录开始时间 start := time.Now() // 获取视频分辨率 videoWidth, _, err := getVideoResolution(ctx, videoPath) if err != nil { return “”, err } // 计算水印目标宽度(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(“视频水印耗时===%f秒”, elapsed.Seconds()) logger.Debug().Msgf(“Watermarked video saved to: %s”, outputPath) return outputPath, nil }
  • 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) { // 解析表单,设置表单内存缓存大小 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 { // 发送失败记录日志 fmt.Printf(“Failed to send file %s: %vn”, 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临时文件路径===%s”, zipPath) }(zipPath) return nil } // HandlerCut 视频截取 func (c *VideoProcessController) HandlerCut(e echo.Context) error { // 解析表单,设置缓存为 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 { // 发送失败记录日志 fmt.Printf(“Failed to send file %s: %vn”, 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 { // 解析表单,设置缓存为 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 { // 发送失败记录日志 fmt.Printf(“Failed to send file %s: %vn”, 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 }
  • 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,具体用法这里不做详细说明了,具体内容请自行查看官网文档
  • 注意:上述代码依赖 ffmpeg 工具,请先确保它已在您的系统中安装并配置好环境变量。如果您尚未安装,可参考官方文档或相关教程完成安装,本文不再详细介绍。详情参见ffmpeg官网 上述代码均使用apifox测试,功能正常 到此这篇关于Golang调用FFmpeg实现视频截图,裁剪与水印添加功能的文章就介绍到这了,更多相关Go FFmpeg视频处理内容请搜索风君子博客以前的文章或继续浏览下面的相关文章希望大家以后多多支持风君子博客! 您可能感兴趣的文章: Golang集成FFmpeg的音视频处理的实现 Golang调用FFmpeg转换视频流的实现 Go利用ffmpeg进行视频和音频处理 Go语言调用ffmpeg-api实现音频重采样 Go语言利用ffmpeg转hls实现简单视频直播
  • 目录
    • 1.视频处理参数
    • 2.视频处理接口及实现
    • 3.视频处理控制器
    • 4.视频处理路由配置
    • 5.其他说明

    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"`    // 开始时间(秒)
    	Duration int64                 `form:"duration"` // 截止时间(秒)
    }
    
    // VideoWatermark 视频水印
    type VideoWatermark struct {
    	Video     *multipart.FileHeader `form:"video"`     // 视频文件
    	Watermark *multipart.FileHeader `form:"watermark"` // 水印文件,必须是png
    }
    

    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 保存上传的文件到临时位置并返回路径
    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("创建临时文件==%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 返回视频时长(秒)
    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 执行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("执行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)
    
    
    	// 创建临时目录存放所有截图
    	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)
    
    	// 记录开始时间
    	startCut := time.Now()
    
    	// 获取视频总时长
    	videoDuration, err := getVideoDuration(ctx, videoPath)
    	if err != nil {
    		return "", err
    	}
    
    	// 起始时间(秒)
    	start := float64(param.Start)
    
    	// 期望截取时长(秒)
    	wantDuration := float64(param.Duration)
    
    	// 剩余可截取的最大时长
    	maxDuration := videoDuration - start
    	if maxDuration <= 0 {
    		return "", fmt.Errorf("start time exceeds video duration")
    	}
    
    	// 实际截取时长
    	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("视频截取耗时===%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()))
    
    	// 记录开始时间
    	start := time.Now()
    
    	// 获取视频分辨率
    	videoWidth, _, err := getVideoResolution(ctx, videoPath)
    	if err != nil {
    		return "", err
    	}
    
    	// 计算水印目标宽度(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("视频水印耗时===%f秒", elapsed.Seconds())
    	logger.Debug().Msgf("Watermarked video saved to: %s", outputPath)
    	return outputPath, nil
    }
    
    

    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) {
    	// 解析表单,设置表单内存缓存大小
    	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 {
    		// 发送失败记录日志
    		fmt.Printf("Failed to send file %s: %vn", 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临时文件路径===%s", zipPath)
    	}(zipPath)
    
    	return nil
    }
    
    // HandlerCut 视频截取
    func (c *VideoProcessController) HandlerCut(e echo.Context) error {
    	// 解析表单,设置缓存为 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 {
    		// 发送失败记录日志
    		fmt.Printf("Failed to send file %s: %vn", 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 {
    	// 解析表单,设置缓存为 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 {
    		// 发送失败记录日志
    		fmt.Printf("Failed to send file %s: %vn", 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
    }
    
    

    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,具体用法这里不做详细说明了,具体内容请自行查看官网文档

    注意:上述代码依赖 ffmpeg 工具,请先确保它已在您的系统中安装并配置好环境变量。如果您尚未安装,可参考官方文档或相关教程完成安装,本文不再详细介绍。详情参见ffmpeg官网

    上述代码均使用apifox测试,功能正常

    到此这篇关于Golang调用FFmpeg实现视频截图,裁剪与水印添加功能的文章就介绍到这了,更多相关Go FFmpeg视频处理内容请搜索风君子博客以前的文章或继续浏览下面的相关文章希望大家以后多多支持风君子博客!

    您可能感兴趣的文章:

    • Golang集成FFmpeg的音视频处理的实现
    • Golang调用FFmpeg转换视频流的实现
    • Go利用ffmpeg进行视频和音频处理
    • Go语言调用ffmpeg-api实现音频重采样
    • Go语言利用ffmpeg转hls实现简单视频直播

    站内搜索