owpengram-server/internal/app/files/gif_transcoder.go

177 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package files
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
gifTranscodeTimeout = 20 * time.Second
gifTranscodeMaxInputBytes = 50 << 20
gifTranscodeMaxOutputBytes = 200 << 20
gifTranscodeMaxConcurrent = 2
)
// GIFVideo 是服务端把真实 GIF 规范化为 Telegram GIFv 后的结果。
type GIFVideo struct {
Data []byte
Width int
Height int
Duration float64
}
// GIFTranscoder 必须输出无声、faststart 的 H.264 MP4并返回可持久化的视频元数据。
type GIFTranscoder interface {
Transcode(ctx context.Context, data []byte) (GIFVideo, error)
}
type FFmpegGIFTranscoder struct {
ffmpeg string
ffprobe string
timeout time.Duration
slots chan struct{}
}
func NewFFmpegGIFTranscoder() (*FFmpegGIFTranscoder, error) {
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
return nil, err
}
ffprobeName := "ffprobe"
if ext := filepath.Ext(ffmpeg); ext != "" {
ffprobeName += ext
}
ffprobe := filepath.Join(filepath.Dir(ffmpeg), ffprobeName)
if _, err := os.Stat(ffprobe); err != nil {
ffprobe, err = exec.LookPath("ffprobe")
if err != nil {
return nil, err
}
}
return &FFmpegGIFTranscoder{
ffmpeg: ffmpeg, ffprobe: ffprobe, timeout: gifTranscodeTimeout,
slots: make(chan struct{}, gifTranscodeMaxConcurrent),
}, nil
}
func (t *FFmpegGIFTranscoder) Transcode(ctx context.Context, data []byte) (GIFVideo, error) {
if t == nil || t.ffmpeg == "" || t.ffprobe == "" {
return GIFVideo{}, fmt.Errorf("gif transcoder unavailable")
}
if len(data) == 0 || len(data) > gifTranscodeMaxInputBytes {
return GIFVideo{}, fmt.Errorf("gif input size out of range: %d", len(data))
}
select {
case t.slots <- struct{}{}:
defer func() { <-t.slots }()
case <-ctx.Done():
return GIFVideo{}, ctx.Err()
}
runCtx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel()
input, err := os.CreateTemp("", "telesrv-gif-*.gif")
if err != nil {
return GIFVideo{}, fmt.Errorf("create gif input: %w", err)
}
inputPath := input.Name()
defer os.Remove(inputPath)
if _, err := input.Write(data); err != nil {
input.Close()
return GIFVideo{}, fmt.Errorf("write gif input: %w", err)
}
if err := input.Close(); err != nil {
return GIFVideo{}, fmt.Errorf("close gif input: %w", err)
}
output, err := os.CreateTemp("", "telesrv-gifv-*.mp4")
if err != nil {
return GIFVideo{}, fmt.Errorf("create gif output: %w", err)
}
outputPath := output.Name()
output.Close()
defer os.Remove(outputPath)
cmd := exec.CommandContext(runCtx, t.ffmpeg,
"-hide_banner", "-loglevel", "error", "-y",
"-i", inputPath, "-map", "0:v:0", "-an",
"-vf", "scale=ceil(iw/2)*2:ceil(ih/2)*2:flags=lanczos",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", outputPath)
stderr, err := cmd.CombinedOutput()
if runCtx.Err() != nil {
return GIFVideo{}, runCtx.Err()
}
if err != nil {
return GIFVideo{}, commandError("ffmpeg gif transcode", err, stderr)
}
info, err := os.Stat(outputPath)
if err != nil || info.Size() <= 0 || info.Size() > gifTranscodeMaxOutputBytes {
return GIFVideo{}, fmt.Errorf("gif output size invalid")
}
meta, err := t.probe(runCtx, outputPath)
if err != nil {
return GIFVideo{}, err
}
video, err := os.ReadFile(outputPath)
if err != nil {
return GIFVideo{}, fmt.Errorf("read gif output: %w", err)
}
meta.Data = video
return meta, nil
}
func (t *FFmpegGIFTranscoder) probe(ctx context.Context, path string) (GIFVideo, error) {
cmd := exec.CommandContext(ctx, t.ffprobe, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,duration:format=duration", "-of", "json", path)
out, err := cmd.CombinedOutput()
if err != nil {
return GIFVideo{}, commandError("ffprobe gif output", err, out)
}
var result struct {
Streams []struct {
Width, Height int
Duration string
} `json:"streams"`
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
if err := json.Unmarshal(out, &result); err != nil || len(result.Streams) != 1 {
return GIFVideo{}, fmt.Errorf("invalid ffprobe gif metadata")
}
duration := parsePositiveFloat(result.Streams[0].Duration)
if duration == 0 {
duration = parsePositiveFloat(result.Format.Duration)
}
stream := result.Streams[0]
if stream.Width <= 0 || stream.Height <= 0 || duration <= 0 {
return GIFVideo{}, fmt.Errorf("incomplete gif video metadata")
}
return GIFVideo{Width: stream.Width, Height: stream.Height, Duration: duration}, nil
}
func parsePositiveFloat(value string) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || v <= 0 {
return 0
}
return v
}
func commandError(op string, err error, output []byte) error {
msg := strings.TrimSpace(string(output))
if len(msg) > 512 {
msg = msg[:512]
}
if msg == "" {
return fmt.Errorf("%s: %w", op, err)
}
return fmt.Errorf("%s: %w: %s", op, err, msg)
}