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

@ -109,6 +109,9 @@ type DocumentSpec struct {
Attributes []DocumentAttribute
Thumb *UploadedFileRef // 可选缩略图上传,生成 doc:<id>:m
ForceFile bool
// NosoundVideo 对应 inputMediaUploadedDocument.nosound_video。真实 GIF 仍会
// 转成 MP4,但该标志存在时按普通无声视频落库,不附 animated 属性(album 路径)。
NosoundVideo bool
}
// FileDownloadRequest 是 upload.getFile 解析后的下载请求;
@ -205,6 +208,8 @@ type DocumentAttribute struct {
Duration float64 `json:"duration,omitempty"`
RoundMessage bool `json:"round_message,omitempty"`
SupportsStreaming bool `json:"supports_streaming,omitempty"`
NoSound bool `json:"no_sound,omitempty"`
VideoCodec string `json:"video_codec,omitempty"`
// audio
AudioDuration int `json:"audio_duration,omitempty"`
@ -350,14 +355,24 @@ func (d Document) stickerMaterialMimeOrExt() string {
return mimeType
}
// IsGif reports whether the document is a savable GIF (documentAttributeAnimated).
// IsGif reports whether the document is a canonical, savable Telegram GIFv.
// Telegram 的 GIF 是无声 MP4;仅有 animated 属性的 raw image/gif 不是合法 saved GIF。
func (d Document) IsGif() bool {
if !strings.EqualFold(strings.TrimSpace(d.MimeType), "video/mp4") {
return false
}
var animated, video bool
for _, attr := range d.Attributes {
if attr.Kind == DocAttrAnimated {
return true
switch attr.Kind {
case DocAttrAnimated:
animated = true
case DocAttrVideo:
if !attr.RoundMessage && attr.W > 0 && attr.H > 0 && attr.Duration > 0 {
video = true
}
}
}
return false
return animated && video
}
// Photo 是已存储的 Telegram 照片(头像或图片消息)。

View file

@ -95,8 +95,8 @@ func ClassifyMediaCategories(media *MessageMedia, entities []MessageEntity) []Me
}
// classifyDocumentCategory 按 TL DocumentAttribute 判定一个文档落入哪个媒体类别。
// 客户端的标签页 UI 以 TL 属性(而非 MIME)为准,故这里也只看属性。优先级:
// sticker(不入任何标签页)> animated(GIF) > audio(music/voice) > video(video/round) > 通用文件。
// 客户端的标签页 UI 以 TL 属性和 GIFv MIME 共同判定。优先级:sticker(不入任何标签页)>
// canonical animated MP4 (GIF) > audio(music/voice) > video(video/round) > 通用文件。
func classifyDocumentCategory(doc *Document) (MediaCategory, bool) {
if doc == nil {
return MediaCategoryNone, false
@ -130,7 +130,7 @@ func classifyDocumentCategory(doc *Document) (MediaCategory, bool) {
switch {
case hasSticker:
return MediaCategoryNone, false // 贴纸/自定义 emoji 不出现在共享媒体
case hasAnimated:
case hasAnimated && doc.IsGif():
return MediaCategoryGif, true
case hasAudio:
if audioVoice {

View file

@ -24,7 +24,8 @@ func TestClassifyMediaCategories(t *testing.T) {
{"poll", &MessageMedia{Kind: MessageMediaKindPoll}, nil, []MediaCategory{MediaCategoryPoll}},
{"video", doc(DocumentAttribute{Kind: DocAttrVideo}), nil, []MediaCategory{MediaCategoryVideo}},
{"round video note", doc(DocumentAttribute{Kind: DocAttrVideo, RoundMessage: true}), nil, []MediaCategory{MediaCategoryRoundVideo}},
{"gif animation", doc(DocumentAttribute{Kind: DocAttrAnimated}), nil, []MediaCategory{MediaCategoryGif}},
{"gif animation", &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{MimeType: "video/mp4", Attributes: []DocumentAttribute{{Kind: DocAttrAnimated}, {Kind: DocAttrVideo, W: 320, H: 240, Duration: 1}}}}, nil, []MediaCategory{MediaCategoryGif}},
{"raw gif is a file", &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{MimeType: "image/gif", Attributes: []DocumentAttribute{{Kind: DocAttrAnimated}}}}, nil, []MediaCategory{MediaCategoryFile}},
{"music", doc(DocumentAttribute{Kind: DocAttrAudio}), nil, []MediaCategory{MediaCategoryMusic}},
{"voice", doc(DocumentAttribute{Kind: DocAttrAudio, Voice: true}), nil, []MediaCategory{MediaCategoryVoice}},
{"generic file", doc(DocumentAttribute{Kind: DocAttrFilename, FileName: "x.pdf"}), nil, []MediaCategory{MediaCategoryFile}},