feat: sync multilayer td integration

This commit is contained in:
A 2026-07-15 13:32:06 +08:00
parent 20a310f6ca
commit 766c5db992
491 changed files with 26235 additions and 35340 deletions

View file

@ -10,7 +10,7 @@ import (
"image"
"image/color"
stddraw "image/draw"
_ "image/jpeg" // 注册 jpeg DecodeConfig,用于读取上传头像/图片尺寸
"image/jpeg"
"image/png"
"io"
"math"
@ -24,8 +24,8 @@ import (
_ "golang.org/x/image/webp" // 注册 webp Decode,用于 custom emoji / sticker 静态缩略图合成
)
// 头像与图片消息共用的尺寸 type:'a' 小图(≤160),'c' 大图,'x' 通用下载尺寸。
// 同一份上传字节在多个 location_key 下建 blob(不做实际缩放,dev 主路径足够)。
// 头像使用真实的 's'(≤150)/'a'(≤160)/'c'(原图) rendition;图片消息使用
// 'm' 缩略与 'x' 大图。每个头像 location_key 的元数据尺寸必须与实际 blob 一致。
// UploadProfilePhoto 把已上传文件组装成头像 Photo,落 blob/photos/profile_photos,并设为当前头像。
func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) {
@ -103,8 +103,8 @@ func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, b
return s.media.GetDocument(ctx, id)
}
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo('a'/'c' 尺寸,匹配 InputPeerPhotoFileLocation
// big/small 与 channelFull 合成尺寸的下载路径),不绑定 profile_photos。用于频道 editPhoto。
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo('s'/'a'/'c' 尺寸,'a'/'c' 匹配
// InputPeerPhotoFileLocation big/small 与 channelFull 下载路径),不绑定 profile_photos。用于频道 editPhoto。
func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) {
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
if err != nil {
@ -113,7 +113,7 @@ func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.Upload
if len(data) == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
return s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data))
return s.createAvatarPhoto(ctx, data)
}
// CreateAvatarVideoFromUpload stores an animated profile video as photo.video_sizes.
@ -151,7 +151,7 @@ func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.U
}
s.blobCache.put(blob.LocationKey, blob)
stillBytes := s.avatarVideoStill(ctx, body, extraSizes)
sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
sizes, err := s.putAvatarStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
if err != nil {
return domain.Photo{}, err
}
@ -192,7 +192,7 @@ func (s *Service) CreateAvatarMarkup(ctx context.Context, size domain.PhotoSize)
}
photoID := randomID()
stillBytes := s.generatedAvatarStill(ctx, size)
sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
sizes, err := s.putAvatarStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
if err != nil {
return domain.Photo{}, err
}
@ -605,6 +605,26 @@ func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSiz
return photo, nil
}
func (s *Service) createAvatarPhoto(ctx context.Context, data []byte) (domain.Photo, error) {
photoID := randomID()
sizes, err := s.putAvatarStaticSizes(ctx, photoID, data, photoSizeSpecsForAvatar(data))
if err != nil {
return domain.Photo{}, err
}
photo := domain.Photo{
ID: photoID,
AccessHash: randomID(),
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
DCID: s.dc,
Sizes: sizes,
}
if err := s.media.PutPhoto(ctx, photo); err != nil {
return domain.Photo{}, err
}
return photo, nil
}
func (s *Service) putPhotoStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) {
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
@ -630,6 +650,87 @@ func (s *Service) putPhotoStaticSizes(ctx context.Context, photoID int64, data [
return sizes, nil
}
// putAvatarStaticSizes stores independently rendered avatar sizes. DrKLO uses the
// photos.uploadProfilePhoto response's closest 150px size as an immediate local
// location, while UserProfilePhoto updates synthesize the canonical 'a' location.
// Keeping a real 's' rendition therefore gives those two states distinct keys and,
// more importantly, keeps every advertised size backed by matching image bytes.
func (s *Service) putAvatarStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) {
src, format, err := image.Decode(bytes.NewReader(data))
if err != nil || src.Bounds().Dx() <= 0 || src.Bounds().Dy() <= 0 {
return nil, domain.ErrPhotoInvalid
}
sizes := make([]domain.PhotoSize, 0, len(specs))
for _, spec := range specs {
rendition, err := avatarRendition(data, src, format, spec)
if err != nil {
return nil, err
}
objectKey, err := s.blobs.Put(ctx, rendition)
if err != nil {
return nil, err
}
blob := domain.FileBlob{
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(rendition)),
MimeType: imageMimeType(rendition),
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return nil, err
}
s.blobCache.put(blob.LocationKey, blob)
s.prewarmSmallBlob(objectKey, rendition)
sizes = append(sizes, domain.PhotoSize{
Kind: domain.PhotoSizeKindDefault,
Type: spec.Type,
W: spec.W,
H: spec.H,
Size: len(rendition),
})
}
return sizes, nil
}
func avatarRendition(original []byte, src image.Image, format string, spec photoSizeSpec) ([]byte, error) {
bounds := src.Bounds()
if bounds.Dx() == spec.W && bounds.Dy() == spec.H {
return append([]byte(nil), original...), nil
}
if spec.W <= 0 || spec.H <= 0 {
return nil, domain.ErrPhotoInvalid
}
dst := image.NewRGBA(image.Rect(0, 0, spec.W, spec.H))
srcRect := centerCropRect(bounds, spec.W, spec.H)
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, srcRect, xdraw.Src, nil)
var buf bytes.Buffer
if format == "jpeg" {
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 90}); err != nil {
return nil, err
}
} else if err := png.Encode(&buf, dst); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func centerCropRect(bounds image.Rectangle, targetW, targetH int) image.Rectangle {
sourceW, sourceH := bounds.Dx(), bounds.Dy()
if int64(sourceW)*int64(targetH) > int64(sourceH)*int64(targetW) {
cropW := maxInt(1, sourceH*targetW/targetH)
x := bounds.Min.X + (sourceW-cropW)/2
return image.Rect(x, bounds.Min.Y, x+cropW, bounds.Max.Y)
}
if int64(sourceW)*int64(targetH) < int64(sourceH)*int64(targetW) {
cropH := maxInt(1, sourceW*targetH/targetW)
y := bounds.Min.Y + (sourceH-cropH)/2
return image.Rect(bounds.Min.X, y, bounds.Max.X, y+cropH)
}
return bounds
}
func (s *Service) putDocumentThumb(ctx context.Context, docID int64, thumbData []byte) (domain.PhotoSize, error) {
if len(thumbData) == 0 {
return domain.PhotoSize{}, fmt.Errorf("empty document thumbnail")
@ -723,12 +824,15 @@ type photoSizeSpec struct {
func photoSizeSpecsForAvatar(data []byte) []photoSizeSpec {
w, h := imageDimensions(data, 640, 640)
small := 160
if w < small {
small = w
shortSide := w
if h < shortSide {
shortSide = h
}
sSize := minInt(shortSide, 150)
aSize := minInt(shortSide, 160)
return []photoSizeSpec{
{Type: "a", W: small, H: small},
{Type: "s", W: sSize, H: sSize},
{Type: "a", W: aSize, H: aSize},
{Type: "c", W: w, H: h},
}
}
@ -767,10 +871,14 @@ const (
avatarMarkupMaxSourceBytes = 2 << 20 // emoji/sticker thumb 小对象保护线。
)
// avatarVideoStill 生成动画头像的静态尺寸字节:优先抽取上传视频首帧——动画头像
// (emoji/sticker 构造器或自选视频)的首帧就是用户在客户端看到的真实画面(彩色
// emoji、圆角、布局都一致);抽帧不可用时回退到按 markup 服务端合成。
// avatarVideoStill 生成动画头像的静态尺寸字节。emoji/sticker markup 能解析到
// 服务端缩略图时优先合成:DrKLO 生成的 MP4 第一帧可能只有背景渐变,直接抽第一帧
// 会让静态头像永久缺少 emoji。普通视频或 markup 资源不可用时才回退 ffmpeg 首帧。
func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob, extraSizes []domain.PhotoSize) []byte {
markup := avatarStillMarkup(extraSizes)
if still, ok := s.generatedAvatarMarkupStill(ctx, markup); ok {
return still
}
if s.thumbs != nil && body.Size > 0 && body.Size <= videoThumbnailMaxInputBytes {
data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, body.Size)
if err == nil && int64(len(data)) == total && total == body.Size {
@ -789,17 +897,30 @@ func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob
zap.Error(err))
}
}
return s.generatedAvatarStill(ctx, avatarStillMarkup(extraSizes))
return s.generatedAvatarStill(ctx, markup)
}
func (s *Service) generatedAvatarStill(ctx context.Context, markup domain.PhotoSize) []byte {
img := generatedAvatarBackground(markup.BackgroundColors)
if overlay, tintWhite, ok := s.avatarMarkupOverlay(ctx, markup); ok {
drawAvatarMarkup(img, overlay, tintWhite)
if still, ok := s.generatedAvatarMarkupStillOnBackground(ctx, markup, img); ok {
return still
}
return encodeAvatarPNG(img)
}
func (s *Service) generatedAvatarMarkupStill(ctx context.Context, markup domain.PhotoSize) ([]byte, bool) {
return s.generatedAvatarMarkupStillOnBackground(ctx, markup, generatedAvatarBackground(markup.BackgroundColors))
}
func (s *Service) generatedAvatarMarkupStillOnBackground(ctx context.Context, markup domain.PhotoSize, img *image.RGBA) ([]byte, bool) {
overlay, tintWhite, ok := s.avatarMarkupOverlay(ctx, markup)
if !ok {
return nil, false
}
drawAvatarMarkup(img, overlay, tintWhite)
return encodeAvatarPNG(img), true
}
func generatedAvatarBackground(colors []int) *image.RGBA {
if len(colors) == 0 {
colors = []int{0x5b8def, 0x53c6a4}
@ -865,6 +986,11 @@ func (s *Service) avatarMarkupOverlay(ctx context.Context, markup domain.PhotoSi
zap.Error(err))
return nil, false, false
}
// Seed 的 1x1 透明图只是“没有可用静态资源”的显式占位,不是可合成
// 内容。把它视为 unavailable,交给调用方回退到 ffmpeg 视频首帧。
if img.Bounds().Dx() <= 1 || img.Bounds().Dy() <= 1 {
return nil, false, false
}
return img, documentIsTextColorEmoji(doc), true
}