feat: sync GIFv and saved GIF support
This commit is contained in:
parent
c0088f1160
commit
5f7c0b9804
21 changed files with 641 additions and 50 deletions
177
internal/app/files/gif_transcoder.go
Normal file
177
internal/app/files/gif_transcoder.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue