feat: auto generate thumbnail for video uploaded to telegram storage

This commit is contained in:
krau
2025-11-28 10:24:15 +08:00
parent fef7d37a7e
commit 1065acfdb8
6 changed files with 164 additions and 14 deletions

1
storage/telegram/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
tests/

View File

@@ -137,29 +137,44 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
if strings.HasPrefix(mtype.String(), "image/") && size >= tglimit.MaxPhotoSize {
forceFile = true
}
docb := message.UploadedDocument(file, caption).
doc := message.UploadedDocument(file, caption).
Filename(filename).
ForceFile(forceFile).
MIME(mtype.String())
var media message.MediaOption = docb
var media message.MediaOption = doc
switch mtypeStr := mtype.String(); {
case strings.HasPrefix(mtypeStr, "video/"):
media = docb.Video().SupportsStreaming()
media = doc.Video().SupportsStreaming()
thumb, err := extractThumbFrame(rs)
if err == nil {
thumb, err := upler.FromBytes(ctx, "thumb.jpg", thumb)
if err == nil {
doc = doc.Thumb(thumb)
}
}
rs.Seek(0, io.SeekStart)
switch mtypeStr {
case "video/mp4":
info, err := getMP4Info(rs)
info, err := getMP4Meta(rs)
if err == nil {
media = docb.Video().
media = doc.Video().
Duration(time.Duration(info.Duration)*time.Second).
Resolution(info.Width, info.Height).
SupportsStreaming()
}
default:
info, err := getVideoMetadata(rs)
if err == nil {
media = doc.Video().
Duration(time.Duration(info.Duration)*time.Second).
Resolution(info.Width, info.Height).
SupportsStreaming()
}
}
case strings.HasPrefix(mtypeStr, "audio/"):
media = docb.Audio().Title(filename)
media = doc.Audio().Title(filename)
case strings.HasPrefix(mtypeStr, "image/") && !strings.HasSuffix(mtypeStr, "webp"):
media = message.UploadedPhoto(file, caption)
}

View File

@@ -1,20 +1,25 @@
package telegram
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
"github.com/krau/ffmpeg-go"
"github.com/yapingcat/gomedia/go-mp4"
)
type MP4Info struct {
type VideoMetadata struct {
Duration int
Width int
Height int
}
func getMP4Info(r io.ReadSeeker) (*MP4Info, error) {
d := mp4.CreateMp4Demuxer(r)
// a go native way to get mp4 video metadata
func getMP4Meta(rs io.ReadSeeker) (*VideoMetadata, error) {
d := mp4.CreateMp4Demuxer(rs)
tracks, err := d.ReadHead()
if err != nil {
@@ -24,7 +29,7 @@ func getMP4Info(r io.ReadSeeker) (*MP4Info, error) {
for _, track := range tracks {
if track.Cid == mp4.MP4_CODEC_H264 {
info := d.GetMp4Info()
return &MP4Info{
return &VideoMetadata{
Duration: int(info.Duration / info.Timescale),
Width: int(track.Width),
Height: int(track.Height),
@@ -34,3 +39,97 @@ func getMP4Info(r io.ReadSeeker) (*MP4Info, error) {
return nil, fmt.Errorf("no h264 track found")
}
// getVideoMetadata uses ffprobe to get video metadata
func getVideoMetadata(rs io.ReadSeeker) (*VideoMetadata, error) {
pipeReader, pipeWriter := io.Pipe()
go func() {
defer pipeWriter.Close()
rs.Seek(0, io.SeekStart)
io.Copy(pipeWriter, rs)
}()
result, err := ffmpeg.ProbeReaderWithTimeout(
pipeReader,
time.Second*10,
ffmpeg.KwArgs{
"select_streams": "v:0",
"show_entries": "stream=width,height:format=duration",
"of": "json",
},
)
if err != nil {
return nil, err
}
var data struct {
Streams []struct {
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
if err := json.Unmarshal([]byte(result), &data); err != nil {
return nil, err
}
// 转换 duration
var durationFloat float64
if data.Format.Duration != "" {
fmt.Sscanf(data.Format.Duration, "%f", &durationFloat)
}
meta := &VideoMetadata{
Duration: int(durationFloat),
}
if len(data.Streams) > 0 {
meta.Width = data.Streams[0].Width
meta.Height = data.Streams[0].Height
}
return meta, nil
}
func extractThumbFrame(rs io.ReadSeeker) ([]byte, error) {
data, err := extractFrameAt(rs, 1.0)
if err == nil && len(data) > 0 {
return data, nil
}
return extractFrameAt(rs, 0.0)
}
func extractFrameAt(rs io.ReadSeeker, timestamp float64) ([]byte, error) {
pipeReader, pipeWriter := io.Pipe()
go func() {
defer pipeWriter.Close()
rs.Seek(0, io.SeekStart)
io.Copy(pipeWriter, rs)
}()
var out bytes.Buffer
err := ffmpeg.
Input("pipe:0", ffmpeg.KwArgs{
"ss": fmt.Sprintf("%.3f", timestamp),
}).
Output("pipe:1", ffmpeg.KwArgs{
"vframes": 1,
"f": "mjpeg",
}).
WithInput(pipeReader).
WithOutput(&out).
OverWriteOutput().
Run()
if err != nil {
return nil, err
}
return out.Bytes(), nil
}

View File

@@ -0,0 +1,34 @@
package telegram
import (
"os"
"testing"
)
func TestExtractThumbFrame(t *testing.T) {
file, err := os.Open("tests/testvideo")
if err != nil {
t.Fatalf("failed to open test video: %v", err)
}
defer file.Close()
thumb, err := extractThumbFrame(file)
if err != nil {
t.Fatalf("failed to extract thumb frame: %v", err)
}
os.WriteFile("tests/testthumb.jpg", thumb, 0644)
}
func TestGetVideoMetadata(t *testing.T) {
file, err := os.Open("tests/testvideo")
if err != nil {
t.Fatalf("failed to open test video: %v", err)
}
defer file.Close()
meta, err := getVideoMetadata(file)
if err != nil {
t.Fatalf("failed to get video metadata: %v", err)
}
if meta.Duration == 0 || meta.Width == 0 || meta.Height == 0 {
t.Fatalf("invalid video metadata: %+v", meta)
}
}