feat: sync GIFv and saved GIF support

This commit is contained in:
A 2026-07-11 21:43:13 +08:00
parent c0088f1160
commit 5f7c0b9804
21 changed files with 641 additions and 50 deletions

View 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)
}

View file

@ -0,0 +1,39 @@
package files
import (
"bytes"
"context"
"image"
"image/color"
"image/gif"
"testing"
)
func TestFFmpegGIFTranscoderProducesCanonicalMP4(t *testing.T) {
transcoder, err := NewFFmpegGIFTranscoder()
if err != nil {
t.Skipf("ffmpeg/ffprobe unavailable: %v", err)
}
palette := color.Palette{color.Black, color.White}
first := image.NewPaletted(image.Rect(0, 0, 3, 5), palette)
second := image.NewPaletted(image.Rect(0, 0, 3, 5), palette)
for i := range second.Pix {
second.Pix[i] = 1
}
var input bytes.Buffer
if err := gif.EncodeAll(&input, &gif.GIF{
Image: []*image.Paletted{first, second}, Delay: []int{10, 10}, LoopCount: 0,
}); err != nil {
t.Fatal(err)
}
result, err := transcoder.Transcode(context.Background(), input.Bytes())
if err != nil {
t.Fatalf("Transcode: %v", err)
}
if len(result.Data) < 12 || string(result.Data[4:8]) != "ftyp" {
t.Fatalf("output is not MP4: %x", result.Data[:min(len(result.Data), 16)])
}
if result.Width != 4 || result.Height != 6 || result.Duration <= 0 {
t.Fatalf("metadata = %+v, want even 4x6 positive duration", result)
}
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"fmt"
"image"
@ -242,6 +243,10 @@ func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.Uplo
if body.Size == 0 {
return domain.Document{}, domain.ErrDocumentInvalid
}
body, spec, err = s.normalizeUploadedGIF(ctx, body, spec)
if err != nil {
return domain.Document{}, err
}
// faststart:MP4 视频若 moov 在末尾,搬到文件头以支持流式播放。普通 Telegram 客户端
// 上传前会做这步;DrKLO 发 story 视频不转码导致 moov 在末尾,TDesktop 流式播放路径
// 无法解复用(av_read_frame Invalid data)。不转码、保留原编码(含 HEVC)。
@ -306,6 +311,55 @@ func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.Uplo
return doc, nil
}
func (s *Service) normalizeUploadedGIF(ctx context.Context, body assembledUploadBlob, spec domain.DocumentSpec) (assembledUploadBlob, domain.DocumentSpec, error) {
if !strings.EqualFold(strings.TrimSpace(spec.MimeType), "image/gif") || spec.ForceFile {
return body, spec, nil
}
if s.gifs == nil || body.Size <= 0 || body.Size > gifTranscodeMaxInputBytes {
return assembledUploadBlob{}, domain.DocumentSpec{}, domain.ErrDocumentInvalid
}
data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, gifTranscodeMaxInputBytes+1)
if err != nil || total != body.Size || int64(len(data)) != body.Size {
return assembledUploadBlob{}, domain.DocumentSpec{}, domain.ErrDocumentInvalid
}
started := time.Now()
converted, err := s.gifs.Transcode(ctx, data)
if err != nil || len(converted.Data) == 0 || converted.Width <= 0 || converted.Height <= 0 || converted.Duration <= 0 {
s.log.Warn("GIF to MP4 conversion failed", zap.Int64("input_bytes", body.Size), zap.Error(err))
return assembledUploadBlob{}, domain.DocumentSpec{}, domain.ErrDocumentInvalid
}
objectKey, err := s.blobs.Put(ctx, converted.Data)
if err != nil {
return assembledUploadBlob{}, domain.DocumentSpec{}, err
}
sum := sha256.Sum256(converted.Data)
body = assembledUploadBlob{ObjectKey: objectKey, Size: int64(len(converted.Data)), SHA256: append([]byte(nil), sum[:]...)}
spec.MimeType = "video/mp4"
spec.Attributes = canonicalGIFVideoAttributes(spec.Attributes, converted, spec.NosoundVideo)
s.log.Info("GIF normalized to MP4",
zap.Int64("input_bytes", total), zap.Int("output_bytes", len(converted.Data)),
zap.Int("width", converted.Width), zap.Int("height", converted.Height),
zap.Float64("duration", converted.Duration), zap.Duration("dur", time.Since(started)))
return body, spec, nil
}
func canonicalGIFVideoAttributes(attrs []domain.DocumentAttribute, video GIFVideo, nosoundVideo bool) []domain.DocumentAttribute {
out := make([]domain.DocumentAttribute, 0, 3)
for _, attr := range attrs {
if attr.Kind == domain.DocAttrFilename {
out = append(out, attr)
}
}
if !nosoundVideo {
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated})
}
out = append(out, domain.DocumentAttribute{
Kind: domain.DocAttrVideo, W: video.Width, H: video.Height,
Duration: video.Duration, SupportsStreaming: true, NoSound: true, VideoCodec: "h264",
})
return out
}
// maxFaststartBytes 限制 faststart 一次性载入内存的视频大小;超过则跳过(流式 faststart
// 复杂度高,超大视频是边角)。与缩略图回退路径同样全量读 blob,内存模式无新增。
const maxFaststartBytes = 200 << 20
@ -423,6 +477,18 @@ func (s *Service) CreateDocumentFromBytes(ctx context.Context, data []byte, spec
if strings.TrimSpace(spec.MimeType) == "" {
spec.MimeType = "application/octet-stream"
}
if strings.EqualFold(strings.TrimSpace(spec.MimeType), "image/gif") && !spec.ForceFile {
if s.gifs == nil || len(data) > gifTranscodeMaxInputBytes {
return domain.Document{}, domain.ErrDocumentInvalid
}
converted, err := s.gifs.Transcode(ctx, data)
if err != nil || len(converted.Data) == 0 || converted.Width <= 0 || converted.Height <= 0 || converted.Duration <= 0 {
return domain.Document{}, domain.ErrDocumentInvalid
}
data = converted.Data
spec.MimeType = "video/mp4"
spec.Attributes = canonicalGIFVideoAttributes(spec.Attributes, converted, spec.NosoundVideo)
}
objectKey, size, sum, err := s.blobs.PutReader(ctx, bytes.NewReader(data))
if err != nil {
return domain.Document{}, err

View file

@ -14,6 +14,76 @@ import (
"telesrv/internal/domain"
)
func TestCreateDocumentFromUploadNormalizesGIFToGIFv(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
transcoder := &fakeGIFTranscoder{result: GIFVideo{Data: []byte("fake-mp4"), Width: 320, Height: 240, Duration: 1.5}}
svc := NewService(media, blobs, 2, WithGIFTranscoder(transcoder), WithVideoThumbnailer(nil))
if _, err := svc.SaveFilePart(ctx, 10, 90, 0, []byte("GIF89a-fake")); err != nil {
t.Fatalf("SaveFilePart: %v", err)
}
doc, err := svc.CreateDocumentFromUpload(ctx,
domain.UploadedFileRef{OwnerUserID: 10, FileID: 90, Parts: 1, Name: "animation.gif"},
domain.DocumentSpec{MimeType: "image/gif", Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrFilename, FileName: "animation.gif"},
{Kind: domain.DocAttrImageSize, W: 320, H: 240},
}})
if err != nil {
t.Fatalf("CreateDocumentFromUpload: %v", err)
}
if transcoder.calls != 1 || doc.MimeType != "video/mp4" || !doc.IsGif() || doc.Size != int64(len("fake-mp4")) {
t.Fatalf("document = %+v calls=%d, want canonical GIFv", doc, transcoder.calls)
}
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", doc.ID))
if err != nil || !ok || blob.MimeType != "video/mp4" {
t.Fatalf("gifv blob = %+v ok=%v err=%v", blob, ok, err)
}
got, err := blobs.Get(ctx, blob.ObjectKey)
if err != nil || !bytes.Equal(got, []byte("fake-mp4")) {
t.Fatalf("gifv bytes=%q err=%v", got, err)
}
}
func TestCreateDocumentFromUploadGIFFlags(t *testing.T) {
tests := []struct {
name string
spec domain.DocumentSpec
wantMime string
wantGIF bool
wantCalls int
}{
{name: "album nosound is normal video", spec: domain.DocumentSpec{MimeType: "image/gif", NosoundVideo: true}, wantMime: "video/mp4", wantGIF: false, wantCalls: 1},
{name: "force file preserves original", spec: domain.DocumentSpec{MimeType: "image/gif", ForceFile: true}, wantMime: "image/gif", wantGIF: false, wantCalls: 0},
}
for i, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatal(err)
}
transcoder := &fakeGIFTranscoder{result: GIFVideo{Data: []byte("mp4"), Width: 10, Height: 12, Duration: 1}}
svc := NewService(media, blobs, 2, WithGIFTranscoder(transcoder), WithVideoThumbnailer(nil))
fileID := int64(910 + i)
if _, err := svc.SaveFilePart(ctx, 10, fileID, 0, []byte("gif")); err != nil {
t.Fatal(err)
}
doc, err := svc.CreateDocumentFromUpload(ctx, domain.UploadedFileRef{OwnerUserID: 10, FileID: fileID, Parts: 1}, tc.spec)
if err != nil {
t.Fatal(err)
}
if doc.MimeType != tc.wantMime || doc.IsGif() != tc.wantGIF || transcoder.calls != tc.wantCalls {
t.Fatalf("doc=%+v calls=%d", doc, transcoder.calls)
}
})
}
}
func TestCreateDocumentFromUploadGeneratesVideoThumbWhenMissing(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
@ -189,6 +259,26 @@ func TestCreateDocumentFromBytesStoresBodyAndAttributes(t *testing.T) {
}
}
func TestCreateDocumentFromBytesNormalizesExternalGIF(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatal(err)
}
transcoder := &fakeGIFTranscoder{result: GIFVideo{Data: []byte("external-mp4"), Width: 20, Height: 10, Duration: 2}}
svc := NewService(media, blobs, 2, WithGIFTranscoder(transcoder), WithVideoThumbnailer(nil))
doc, err := svc.CreateDocumentFromBytes(ctx, []byte("external-gif"), domain.DocumentSpec{
MimeType: "image/gif", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "x.gif"}},
})
if err != nil {
t.Fatal(err)
}
if !doc.IsGif() || doc.MimeType != "video/mp4" || transcoder.calls != 1 {
t.Fatalf("doc=%+v calls=%d", doc, transcoder.calls)
}
}
func TestCreateAvatarMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
@ -442,6 +532,17 @@ type fakeVideoThumbnailer struct {
err error
}
type fakeGIFTranscoder struct {
result GIFVideo
err error
calls int
}
func (f *fakeGIFTranscoder) Transcode(_ context.Context, _ []byte) (GIFVideo, error) {
f.calls++
return f.result, f.err
}
func (f *fakeVideoThumbnailer) Extract(context.Context, []byte, string) ([]byte, error) {
f.calls++
if f.err != nil {

View file

@ -648,7 +648,7 @@ func seedDocumentAttributes(attrs []seedAttrJSON) []domain.DocumentAttribute {
}
out = append(out, attr)
case "DocumentAttributeVideo":
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: a.W, H: a.H, Duration: a.Duration, RoundMessage: a.RoundMessage, SupportsStreaming: a.SupportsStreaming})
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: a.W, H: a.H, Duration: a.Duration, RoundMessage: a.RoundMessage, SupportsStreaming: a.SupportsStreaming, NoSound: a.NoSound, VideoCodec: a.VideoCodec})
case "DocumentAttributeAudio":
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: int(a.Duration), Voice: a.Voice, Title: a.Title, Performer: a.Performer})
case "DocumentAttributeFilename":
@ -889,6 +889,8 @@ type seedAttrJSON struct {
Duration float64 `json:"duration"`
RoundMessage bool `json:"round_message"`
SupportsStreaming bool `json:"supports_streaming"`
NoSound bool `json:"nosound"`
VideoCodec string `json:"video_codec"`
Voice bool `json:"voice"`
Title string `json:"title"`
Performer string `json:"performer"`

View file

@ -52,6 +52,8 @@ type Service struct {
log *zap.Logger
thumbs VideoThumbnailer
thumbsSet bool
gifs GIFTranscoder
gifsSet bool
blobCache *blobMetaCache
byteCache *blobBytesCache
// blobMetaSF/blobBytesSF 合并对同一热 blob 的并发首次访问:否则每个并发 getFile 都各打
@ -91,6 +93,14 @@ func WithVideoThumbnailer(thumbnailer VideoThumbnailer) Option {
}
}
// WithGIFTranscoder 覆盖真实 GIF→MP4 规范化器。传 nil 可用于测试不可用路径。
func WithGIFTranscoder(transcoder GIFTranscoder) Option {
return func(s *Service) {
s.gifs = transcoder
s.gifsSet = true
}
}
// WithUploadPartQuota 覆盖用户级 in-flight 上传分片配额;字段 <=0 表示该维度不限制。
func WithUploadPartQuota(quota domain.UploadPartQuota) Option {
return func(s *Service) {
@ -135,6 +145,14 @@ func NewService(media store.MediaStore, blobs BlobBackend, dc int, opts ...Optio
s.thumbs = thumbnailer
}
}
if !s.gifsSet {
transcoder, err := NewFFmpegGIFTranscoder()
if err != nil {
s.log.Warn("ffmpeg/ffprobe not found; GIF uploads will be rejected", zap.Error(err))
} else {
s.gifs = transcoder
}
}
return s
}