chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
|
|
@ -7,11 +7,20 @@ import (
|
|||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
stddraw "image/draw"
|
||||
_ "image/jpeg" // 注册 jpeg DecodeConfig,用于读取上传头像/图片尺寸
|
||||
_ "image/png" // 注册 png DecodeConfig
|
||||
"image/png"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
||||
"go.uber.org/zap"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp" // 注册 webp Decode,用于 custom emoji / sticker 静态缩略图合成
|
||||
)
|
||||
|
||||
// 头像与图片消息共用的尺寸 type:'a' 小图(≤160),'c' 大图,'x' 通用下载尺寸。
|
||||
|
|
@ -24,17 +33,10 @@ func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerT
|
|||
|
||||
// UploadProfilePhotoKind stores a profile or fallback photo and makes it current for that kind.
|
||||
func (s *Service) UploadProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, file domain.UploadedFileRef, date int) (domain.Photo, error) {
|
||||
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
photo, err := s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data))
|
||||
photo, err := s.CreateAvatarFromUpload(ctx, file)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
|
|
@ -56,6 +58,14 @@ func (s *Service) CreatePhotoFromUpload(ctx context.Context, file domain.Uploade
|
|||
return s.createPhoto(ctx, data, photoSizeSpecsForMessage(data))
|
||||
}
|
||||
|
||||
// CreatePhotoFromBytes stores already-fetched image bytes as a message Photo.
|
||||
func (s *Service) CreatePhotoFromBytes(ctx context.Context, data []byte) (domain.Photo, error) {
|
||||
if len(data) == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
return s.createPhoto(ctx, data, photoSizeSpecsForMessage(data))
|
||||
}
|
||||
|
||||
// GetPhoto 按 id 返回已存储照片。
|
||||
func (s *Service) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) {
|
||||
return s.media.GetPhoto(ctx, id)
|
||||
|
|
@ -79,25 +89,137 @@ func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.Upload
|
|||
return s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data))
|
||||
}
|
||||
|
||||
// CreateAvatarVideoFromUpload stores an animated profile video as photo.video_sizes.
|
||||
func (s *Service) CreateAvatarVideoFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64) (domain.Photo, error) {
|
||||
return s.createAvatarVideoFromUpload(ctx, file, videoStartTs, nil)
|
||||
}
|
||||
|
||||
// CreateAvatarVideoMarkupFromUpload stores Android-style generated avatar video plus its emoji/sticker markup.
|
||||
func (s *Service) CreateAvatarVideoMarkupFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64, markup domain.PhotoSize) (domain.Photo, error) {
|
||||
if err := validateAvatarMarkupSize(markup); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
return s.createAvatarVideoFromUpload(ctx, file, videoStartTs, []domain.PhotoSize{markup})
|
||||
}
|
||||
|
||||
func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64, extraSizes []domain.PhotoSize) (domain.Photo, error) {
|
||||
body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if body.Size == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
photoID := randomID()
|
||||
blob := domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("photo:%d:u", photoID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: body.ObjectKey,
|
||||
Size: body.Size,
|
||||
SHA256: body.SHA256,
|
||||
MimeType: "video/mp4",
|
||||
}
|
||||
if err := s.media.PutFileBlob(ctx, blob); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
s.blobCache.put(blob.LocationKey, blob)
|
||||
stillBytes := s.avatarVideoStill(ctx, body, extraSizes)
|
||||
sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
sizes = append(sizes, domain.PhotoSize{
|
||||
Kind: domain.PhotoSizeKindVideo,
|
||||
Type: "u",
|
||||
W: 640,
|
||||
H: 640,
|
||||
Size: int(body.Size),
|
||||
VideoStartTs: videoStartTs,
|
||||
})
|
||||
sizes = append(sizes, extraSizes...)
|
||||
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
|
||||
}
|
||||
if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil {
|
||||
s.log.Warn("cleanup assembled avatar video upload parts failed",
|
||||
zap.Int64("owner_user_id", file.OwnerUserID),
|
||||
zap.Int64("file_id", file.FileID),
|
||||
zap.Int64("photo_id", photoID),
|
||||
zap.Error(err))
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
// CreateAvatarMarkup stores an emoji/sticker animated profile markup as photo.video_sizes.
|
||||
func (s *Service) CreateAvatarMarkup(ctx context.Context, size domain.PhotoSize) (domain.Photo, error) {
|
||||
if err := validateAvatarMarkupSize(size); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
photoID := randomID()
|
||||
stillBytes := s.generatedAvatarStill(ctx, size)
|
||||
sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
sizes = append(sizes, size)
|
||||
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 validateAvatarMarkupSize(size domain.PhotoSize) error {
|
||||
switch size.Kind {
|
||||
case domain.PhotoSizeKindVideoEmojiMarkup:
|
||||
if size.EmojiID == 0 || len(size.BackgroundColors) == 0 {
|
||||
return domain.ErrPhotoInvalid
|
||||
}
|
||||
case domain.PhotoSizeKindVideoStickerMarkup:
|
||||
if size.StickerID == 0 || len(size.BackgroundColors) == 0 {
|
||||
return domain.ErrPhotoInvalid
|
||||
}
|
||||
default:
|
||||
return domain.ErrPhotoInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateDocumentFromUpload 把已上传文件组装成 Document(文件/视频/音频/gif/贴纸消息),落 blob + documents。
|
||||
func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) {
|
||||
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
if body.Size == 0 {
|
||||
return domain.Document{}, domain.ErrDocumentInvalid
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
// faststart:MP4 视频若 moov 在末尾,搬到文件头以支持流式播放。普通 Telegram 客户端
|
||||
// 上传前会做这步;DrKLO 发 story 视频不转码导致 moov 在末尾,TDesktop 流式播放路径
|
||||
// 无法解复用(av_read_frame Invalid data)。不转码、保留原编码(含 HEVC)。
|
||||
body = s.maybeFaststartVideoBlob(ctx, spec.MimeType, body)
|
||||
docID := randomID()
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", docID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
ObjectKey: body.ObjectKey,
|
||||
Size: body.Size,
|
||||
SHA256: body.SHA256,
|
||||
MimeType: spec.MimeType,
|
||||
}); err != nil {
|
||||
return domain.Document{}, err
|
||||
|
|
@ -108,28 +230,183 @@ func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.Uplo
|
|||
FileReference: randomFileReference(),
|
||||
Date: int(time.Now().Unix()),
|
||||
MimeType: spec.MimeType,
|
||||
Size: int64(len(data)),
|
||||
Size: body.Size,
|
||||
DCID: s.dc,
|
||||
Attributes: spec.Attributes,
|
||||
}
|
||||
if spec.Thumb != nil {
|
||||
thumbData, err := s.assembleUpload(ctx, spec.Thumb.OwnerUserID, spec.Thumb.FileID, spec.Thumb.Parts)
|
||||
if err == nil && len(thumbData) > 0 {
|
||||
thumbKey, err := s.blobs.Put(ctx, thumbData)
|
||||
if err == nil {
|
||||
w, h := imageDimensions(thumbData, 0, 0)
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d:m", docID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: thumbKey,
|
||||
Size: int64(len(thumbData)),
|
||||
MimeType: "image/jpeg",
|
||||
}); err == nil {
|
||||
doc.Thumbs = []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "m", W: w, H: h, Size: len(thumbData)}}
|
||||
}
|
||||
if thumb, err := s.putDocumentThumb(ctx, docID, thumbData); err == nil {
|
||||
doc.Thumbs = []domain.PhotoSize{thumb}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(doc.Thumbs) == 0 {
|
||||
if thumb, ok := s.generateVideoThumbFallbackFromBlob(ctx, docID, body.ObjectKey, body.Size, spec); ok {
|
||||
doc.Thumbs = []domain.PhotoSize{thumb}
|
||||
}
|
||||
}
|
||||
if err := s.media.PutDocument(ctx, doc); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil {
|
||||
s.log.Warn("cleanup assembled document upload parts failed",
|
||||
zap.Int64("owner_user_id", file.OwnerUserID),
|
||||
zap.Int64("file_id", file.FileID),
|
||||
zap.Int64("document_id", docID),
|
||||
zap.Error(err))
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// maxFaststartBytes 限制 faststart 一次性载入内存的视频大小;超过则跳过(流式 faststart
|
||||
// 复杂度高,超大视频是边角)。与缩略图回退路径同样全量读 blob,内存模式无新增。
|
||||
const maxFaststartBytes = 200 << 20
|
||||
|
||||
// faststartVideoMimes 是会用 moov/mdat 结构、值得尝试 faststart 的容器 mime。
|
||||
// 其它 video/*(webm 等)结构不同,faststartMP4 也会自检后 no-op,故不在此列以免白读 blob。
|
||||
var faststartVideoMimes = map[string]bool{
|
||||
"video/mp4": true,
|
||||
"video/quicktime": true,
|
||||
"video/x-m4v": true,
|
||||
}
|
||||
|
||||
// maybeFaststartVideoBlob 对 MP4/MOV 视频上传尝试 faststart。性能考量:
|
||||
// 1. 先只读顶层 box 头(廉价探测)判断是否需要——绝大多数客户端上传的视频本就已 faststart,
|
||||
// 此时只发生几次 16 字节读,不读整段媒体。
|
||||
// 2. 仅 moov 在末尾时才重写;且优先走流式(仅 ftyp+moov 进内存,mdat 大块分块流式拼接),
|
||||
// 不把整段视频 2× 驻留内存。moov 非末尾的罕见排布回退到全量重排。
|
||||
// 任何不适用/失败都返回原 body,绝不让上传失败或损坏数据。
|
||||
func (s *Service) maybeFaststartVideoBlob(ctx context.Context, mimeType string, body assembledUploadBlob) assembledUploadBlob {
|
||||
if !faststartVideoMimes[strings.ToLower(strings.TrimSpace(mimeType))] {
|
||||
return body
|
||||
}
|
||||
if body.Size <= 0 || body.Size > maxFaststartBytes {
|
||||
return body
|
||||
}
|
||||
readAt := func(off, n int64) ([]byte, error) {
|
||||
data, _, err := s.blobs.GetRange(ctx, body.ObjectKey, off, n)
|
||||
return data, err
|
||||
}
|
||||
layout, ok := inspectMP4Layout(body.Size, readAt)
|
||||
if !ok || !layout.needsFaststart {
|
||||
return body // 非 MP4 / 已 faststart —— 未读整段媒体
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
if layout.moovIsLast {
|
||||
// 流式重写:只把 ftyp + moov 读进内存并 patch 偏移,mdat 区段分块流式。
|
||||
ftyp, e1 := readAt(layout.ftypStart, layout.ftypEnd-layout.ftypStart)
|
||||
moov, e2 := readAt(layout.moovStart, layout.moovEnd-layout.moovStart)
|
||||
moovSize := layout.moovEnd - layout.moovStart
|
||||
if e1 != nil || e2 != nil ||
|
||||
int64(len(ftyp)) != layout.ftypEnd-layout.ftypStart ||
|
||||
int64(len(moov)) != moovSize ||
|
||||
!patchChunkOffsets(moov, moovSize) {
|
||||
return body
|
||||
}
|
||||
mid := &blobRangeReader{ctx: ctx, blobs: s.blobs, key: body.ObjectKey, pos: layout.ftypEnd, end: layout.moovStart}
|
||||
reader = io.MultiReader(bytes.NewReader(ftyp), bytes.NewReader(moov), mid)
|
||||
} else {
|
||||
// 罕见:moov 非末尾。回退到全量读 + 重排(已测函数)。
|
||||
data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, body.Size)
|
||||
if err != nil || total != body.Size || int64(len(data)) != body.Size {
|
||||
return body
|
||||
}
|
||||
out, changed := faststartMP4(data)
|
||||
if !changed {
|
||||
return body
|
||||
}
|
||||
reader = bytes.NewReader(out)
|
||||
}
|
||||
|
||||
key, size, sum, err := s.blobs.PutReader(ctx, reader)
|
||||
if err != nil {
|
||||
s.log.Warn("faststart re-store failed; keeping original blob",
|
||||
zap.String("mime", mimeType), zap.Int64("size", body.Size), zap.Error(err))
|
||||
return body
|
||||
}
|
||||
if size != body.Size {
|
||||
// faststart 守恒大小;不等说明流式拼接出错,丢弃新 blob 用原 blob 兜底。
|
||||
s.log.Warn("faststart size mismatch; keeping original blob",
|
||||
zap.Int64("orig", body.Size), zap.Int64("got", size))
|
||||
return body
|
||||
}
|
||||
s.log.Info("faststart applied to uploaded video",
|
||||
zap.String("mime", mimeType), zap.Int64("size", size))
|
||||
return assembledUploadBlob{ObjectKey: key, Size: size, SHA256: sum}
|
||||
}
|
||||
|
||||
// blobRangeReader 把 blob 的 [pos, end) 区段按 io.Reader 调用方给的缓冲大小分块流式读出,
|
||||
// 用于 faststart 流式拼接 mdat,避免整段媒体驻留内存。
|
||||
type blobRangeReader struct {
|
||||
ctx context.Context
|
||||
blobs BlobBackend
|
||||
key string
|
||||
pos int64
|
||||
end int64
|
||||
}
|
||||
|
||||
func (r *blobRangeReader) Read(p []byte) (int, error) {
|
||||
if r.pos >= r.end {
|
||||
return 0, io.EOF
|
||||
}
|
||||
want := r.end - r.pos
|
||||
if want > int64(len(p)) {
|
||||
want = int64(len(p))
|
||||
}
|
||||
data, _, err := r.blobs.GetRange(r.ctx, r.key, r.pos, want)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return 0, io.ErrUnexpectedEOF // 区段内不应读到空,避免静默截断
|
||||
}
|
||||
n := copy(p, data)
|
||||
r.pos += int64(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CreateDocumentFromBytes stores already-fetched bytes as a message Document.
|
||||
func (s *Service) CreateDocumentFromBytes(ctx context.Context, data []byte, spec domain.DocumentSpec) (domain.Document, error) {
|
||||
if len(data) == 0 {
|
||||
return domain.Document{}, domain.ErrDocumentInvalid
|
||||
}
|
||||
if strings.TrimSpace(spec.MimeType) == "" {
|
||||
spec.MimeType = "application/octet-stream"
|
||||
}
|
||||
objectKey, size, sum, err := s.blobs.PutReader(ctx, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
if size == 0 {
|
||||
return domain.Document{}, domain.ErrDocumentInvalid
|
||||
}
|
||||
docID := randomID()
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", docID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: size,
|
||||
SHA256: sum,
|
||||
MimeType: spec.MimeType,
|
||||
}); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
doc := domain.Document{
|
||||
ID: docID,
|
||||
AccessHash: randomID(),
|
||||
FileReference: randomFileReference(),
|
||||
Date: int(time.Now().Unix()),
|
||||
MimeType: spec.MimeType,
|
||||
Size: size,
|
||||
DCID: s.dc,
|
||||
Attributes: spec.Attributes,
|
||||
}
|
||||
if thumb, ok := s.generateVideoThumbFallbackFromBlob(ctx, docID, objectKey, size, spec); ok {
|
||||
doc.Thumbs = []domain.PhotoSize{thumb}
|
||||
}
|
||||
if err := s.media.PutDocument(ctx, doc); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
|
|
@ -177,19 +454,7 @@ func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerTyp
|
|||
|
||||
// GetProfilePhotosKind returns profile/fallback photo history.
|
||||
func (s *Service) GetProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) {
|
||||
ids, total, err := s.media.ListProfilePhotosKind(ctx, ownerType, ownerID, kind, offset, limit, maxID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
photos := make([]domain.Photo, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if p, ok, err := s.media.GetPhoto(ctx, id); err != nil {
|
||||
return nil, 0, err
|
||||
} else if ok {
|
||||
photos = append(photos, p)
|
||||
}
|
||||
}
|
||||
return photos, total, nil
|
||||
return s.media.ListProfilePhotoDetailsKind(ctx, ownerType, ownerID, kind, offset, limit, maxID)
|
||||
}
|
||||
|
||||
// DeleteProfilePhotos 停用指定头像,返回成功停用数量。
|
||||
|
|
@ -208,24 +473,11 @@ func (s *Service) DeleteProfilePhotosKind(ctx context.Context, ownerType domain.
|
|||
|
||||
// createPhoto 把字节落 blob(每个尺寸一个 location_key,指向同一内容)并写 photos 表。
|
||||
func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSizeSpec) (domain.Photo, error) {
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
photoID := randomID()
|
||||
sizes, err := s.putPhotoStaticSizes(ctx, photoID, data, specs)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
photoID := randomID()
|
||||
sizes := make([]domain.PhotoSize, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: "image/jpeg",
|
||||
}); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
sizes = append(sizes, domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: spec.Type, W: spec.W, H: spec.H, Size: len(data)})
|
||||
}
|
||||
photo := domain.Photo{
|
||||
ID: photoID,
|
||||
AccessHash: randomID(),
|
||||
|
|
@ -240,6 +492,116 @@ func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSiz
|
|||
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 {
|
||||
return nil, err
|
||||
}
|
||||
mimeType := imageMimeType(data)
|
||||
sizes := make([]domain.PhotoSize, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
blob := domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: mimeType,
|
||||
}
|
||||
if err := s.media.PutFileBlob(ctx, blob); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.blobCache.put(blob.LocationKey, blob)
|
||||
sizes = append(sizes, domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: spec.Type, W: spec.W, H: spec.H, Size: len(data)})
|
||||
}
|
||||
s.prewarmSmallBlob(objectKey, data)
|
||||
return sizes, nil
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
thumbKey, err := s.blobs.Put(ctx, thumbData)
|
||||
if err != nil {
|
||||
return domain.PhotoSize{}, err
|
||||
}
|
||||
w, h := imageDimensions(thumbData, 0, 0)
|
||||
blob := domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d:m", docID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: thumbKey,
|
||||
Size: int64(len(thumbData)),
|
||||
MimeType: "image/jpeg",
|
||||
}
|
||||
if err := s.media.PutFileBlob(ctx, blob); err != nil {
|
||||
return domain.PhotoSize{}, err
|
||||
}
|
||||
s.blobCache.put(blob.LocationKey, blob)
|
||||
s.prewarmSmallBlob(thumbKey, thumbData)
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: "m", W: w, H: h, Size: len(thumbData)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) generateVideoThumbFallback(ctx context.Context, docID int64, data []byte, spec domain.DocumentSpec) (domain.PhotoSize, bool) {
|
||||
if s.thumbs == nil || !documentSpecIsVideo(spec) || len(data) > videoThumbnailMaxInputBytes {
|
||||
return domain.PhotoSize{}, false
|
||||
}
|
||||
thumbData, err := s.thumbs.Extract(ctx, data, spec.MimeType)
|
||||
if err != nil {
|
||||
s.log.Warn("server-side video thumbnail fallback failed",
|
||||
zap.Int64("document_id", docID),
|
||||
zap.String("mime_type", spec.MimeType),
|
||||
zap.Int64("bytes", int64(len(data))),
|
||||
zap.Error(err))
|
||||
return domain.PhotoSize{}, false
|
||||
}
|
||||
thumb, err := s.putDocumentThumb(ctx, docID, thumbData)
|
||||
if err != nil {
|
||||
s.log.Warn("store server-side video thumbnail failed",
|
||||
zap.Int64("document_id", docID),
|
||||
zap.String("mime_type", spec.MimeType),
|
||||
zap.Int64("thumb_bytes", int64(len(thumbData))),
|
||||
zap.Error(err))
|
||||
return domain.PhotoSize{}, false
|
||||
}
|
||||
return thumb, true
|
||||
}
|
||||
|
||||
func (s *Service) generateVideoThumbFallbackFromBlob(ctx context.Context, docID int64, objectKey string, size int64, spec domain.DocumentSpec) (domain.PhotoSize, bool) {
|
||||
if s.thumbs == nil || !documentSpecIsVideo(spec) || size > videoThumbnailMaxInputBytes {
|
||||
return domain.PhotoSize{}, false
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, objectKey, 0, size)
|
||||
if err != nil {
|
||||
s.log.Warn("read video blob for thumbnail fallback failed",
|
||||
zap.Int64("document_id", docID),
|
||||
zap.String("mime_type", spec.MimeType),
|
||||
zap.Int64("bytes", size),
|
||||
zap.Error(err))
|
||||
return domain.PhotoSize{}, false
|
||||
}
|
||||
if int64(len(data)) != total || total != size {
|
||||
s.log.Warn("video blob size mismatch for thumbnail fallback",
|
||||
zap.Int64("document_id", docID),
|
||||
zap.Int64("expected_size", size),
|
||||
zap.Int64("total_size", total),
|
||||
zap.Int("read_bytes", len(data)))
|
||||
return domain.PhotoSize{}, false
|
||||
}
|
||||
return s.generateVideoThumbFallback(ctx, docID, data, spec)
|
||||
}
|
||||
|
||||
func documentSpecIsVideo(spec domain.DocumentSpec) bool {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(spec.MimeType)), "video/") {
|
||||
return true
|
||||
}
|
||||
for _, attr := range spec.Attributes {
|
||||
if attr.Kind == domain.DocAttrVideo {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type photoSizeSpec struct {
|
||||
Type string
|
||||
W int
|
||||
|
|
@ -286,6 +648,281 @@ func scaleDown(w, h, max int) (int, int) {
|
|||
return max * w / h, max
|
||||
}
|
||||
|
||||
const (
|
||||
avatarStillSize = 640
|
||||
avatarMarkupScale = 0.70
|
||||
avatarMarkupMaxSourceBytes = 2 << 20 // emoji/sticker thumb 小对象保护线。
|
||||
)
|
||||
|
||||
// avatarVideoStill 生成动画头像的静态尺寸字节:优先抽取上传视频首帧——动画头像
|
||||
// (emoji/sticker 构造器或自选视频)的首帧就是用户在客户端看到的真实画面(彩色
|
||||
// emoji、圆角、布局都一致);抽帧不可用时回退到按 markup 服务端合成。
|
||||
func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob, extraSizes []domain.PhotoSize) []byte {
|
||||
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 {
|
||||
if thumb, err := s.thumbs.Extract(ctx, data, "video/mp4"); err == nil && len(thumb) > 0 {
|
||||
return thumb
|
||||
} else if err != nil {
|
||||
s.log.Debug("extract avatar video first frame failed, falling back to composed still",
|
||||
zap.String("object_key", body.ObjectKey),
|
||||
zap.Int64("bytes", body.Size),
|
||||
zap.Error(err))
|
||||
}
|
||||
} else if err != nil {
|
||||
s.log.Warn("read avatar video blob for still failed",
|
||||
zap.String("object_key", body.ObjectKey),
|
||||
zap.Int64("bytes", body.Size),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
return s.generatedAvatarStill(ctx, avatarStillMarkup(extraSizes))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return encodeAvatarPNG(img)
|
||||
}
|
||||
|
||||
func generatedAvatarBackground(colors []int) *image.RGBA {
|
||||
if len(colors) == 0 {
|
||||
colors = []int{0x5b8def, 0x53c6a4}
|
||||
}
|
||||
first := rgbColor(colors[0])
|
||||
last := first
|
||||
if len(colors) > 1 {
|
||||
last = rgbColor(colors[len(colors)-1])
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, avatarStillSize, avatarStillSize))
|
||||
for y := 0; y < avatarStillSize; y++ {
|
||||
t := float64(y) / float64(avatarStillSize-1)
|
||||
row := lerpColor(first, last, t)
|
||||
for x := 0; x < avatarStillSize; x++ {
|
||||
img.SetRGBA(x, y, row)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func encodeAvatarPNG(img image.Image) []byte {
|
||||
var buf bytes.Buffer
|
||||
_ = png.Encode(&buf, img)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// avatarMarkupOverlay 加载 markup 引用文档的静态缩略图作为合成贴图。第二个返回值
|
||||
// 表示是否染白:仅 text_color 的 custom emoji(单色、由客户端按文字色适配渲染,
|
||||
// 头像背景上的约定呈现是白色剪影)需要染白,普通彩色 emoji / sticker 保留原色。
|
||||
func (s *Service) avatarMarkupOverlay(ctx context.Context, markup domain.PhotoSize) (image.Image, bool, bool) {
|
||||
if s == nil || s.media == nil {
|
||||
return nil, false, false
|
||||
}
|
||||
docID := int64(0)
|
||||
switch markup.Kind {
|
||||
case domain.PhotoSizeKindVideoEmojiMarkup:
|
||||
docID = markup.EmojiID
|
||||
case domain.PhotoSizeKindVideoStickerMarkup:
|
||||
docID = markup.StickerID
|
||||
default:
|
||||
return nil, false, false
|
||||
}
|
||||
if docID == 0 {
|
||||
return nil, false, false
|
||||
}
|
||||
doc, found, err := s.media.GetDocument(ctx, docID)
|
||||
if err != nil {
|
||||
s.log.Warn("load avatar markup document failed", zap.Int64("document_id", docID), zap.Error(err))
|
||||
return nil, false, false
|
||||
}
|
||||
if !found {
|
||||
return nil, false, false
|
||||
}
|
||||
data, ok := s.avatarMarkupBytes(ctx, doc)
|
||||
if !ok {
|
||||
return nil, false, false
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
s.log.Debug("decode avatar markup thumbnail failed",
|
||||
zap.Int64("document_id", doc.ID),
|
||||
zap.String("mime_type", doc.MimeType),
|
||||
zap.Error(err))
|
||||
return nil, false, false
|
||||
}
|
||||
return img, documentIsTextColorEmoji(doc), true
|
||||
}
|
||||
|
||||
// documentIsTextColorEmoji 判断文档是否为声明 text_color 的 custom emoji。
|
||||
func documentIsTextColorEmoji(doc domain.Document) bool {
|
||||
for _, attr := range doc.Attributes {
|
||||
if attr.Kind == domain.DocAttrCustomEmoji {
|
||||
return attr.TextColor
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) avatarMarkupBytes(ctx context.Context, doc domain.Document) ([]byte, bool) {
|
||||
var best []byte
|
||||
bestScore := -1
|
||||
for _, thumb := range doc.Thumbs {
|
||||
score := avatarThumbScore(thumb)
|
||||
if score <= bestScore {
|
||||
continue
|
||||
}
|
||||
switch thumb.Kind {
|
||||
case domain.PhotoSizeKindCached:
|
||||
if len(thumb.Bytes) == 0 || len(thumb.Bytes) > avatarMarkupMaxSourceBytes {
|
||||
continue
|
||||
}
|
||||
best = append([]byte(nil), thumb.Bytes...)
|
||||
bestScore = score
|
||||
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive:
|
||||
if thumb.Type == "" || thumb.Size <= 0 || thumb.Size > avatarMarkupMaxSourceBytes {
|
||||
continue
|
||||
}
|
||||
data, ok := s.readSmallBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type), int64(thumb.Size))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
best = data
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
if len(best) > 0 {
|
||||
return best, true
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(doc.MimeType)), "image/") &&
|
||||
doc.Size > 0 && doc.Size <= avatarMarkupMaxSourceBytes {
|
||||
return s.readSmallBlob(ctx, fmt.Sprintf("doc:%d", doc.ID), doc.Size)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *Service) readSmallBlob(ctx context.Context, locationKey string, expectedSize int64) ([]byte, bool) {
|
||||
if expectedSize <= 0 || expectedSize > avatarMarkupMaxSourceBytes {
|
||||
return nil, false
|
||||
}
|
||||
blob, found, err := s.media.GetFileBlob(ctx, locationKey)
|
||||
if err != nil {
|
||||
s.log.Warn("load avatar markup blob metadata failed",
|
||||
zap.String("location_key", locationKey),
|
||||
zap.Error(err))
|
||||
return nil, false
|
||||
}
|
||||
if !found || blob.Size <= 0 || blob.Size > avatarMarkupMaxSourceBytes {
|
||||
return nil, false
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
|
||||
if err != nil {
|
||||
s.log.Warn("read avatar markup blob failed",
|
||||
zap.String("location_key", locationKey),
|
||||
zap.String("object_key", blob.ObjectKey),
|
||||
zap.Error(err))
|
||||
return nil, false
|
||||
}
|
||||
if int64(len(data)) != total || total != blob.Size {
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
func avatarThumbScore(size domain.PhotoSize) int {
|
||||
if size.W > 0 && size.H > 0 {
|
||||
return size.W * size.H
|
||||
}
|
||||
if size.Size > 0 {
|
||||
return size.Size
|
||||
}
|
||||
return len(size.Bytes)
|
||||
}
|
||||
|
||||
// drawAvatarMarkup 把贴图缩放后居中画到背景上。默认保留贴图原色(彩色 emoji /
|
||||
// sticker 头像),仅 tintWhite 时染成白色剪影。
|
||||
func drawAvatarMarkup(dst *image.RGBA, src image.Image, tintWhite bool) {
|
||||
srcBounds := src.Bounds()
|
||||
sw, sh := srcBounds.Dx(), srcBounds.Dy()
|
||||
if sw <= 0 || sh <= 0 {
|
||||
return
|
||||
}
|
||||
max := int(math.Round(float64(dst.Bounds().Dx()) * avatarMarkupScale))
|
||||
if max <= 0 {
|
||||
return
|
||||
}
|
||||
scale := math.Min(float64(max)/float64(sw), float64(max)/float64(sh))
|
||||
w := maxInt(1, int(math.Round(float64(sw)*scale)))
|
||||
h := maxInt(1, int(math.Round(float64(sh)*scale)))
|
||||
rect := image.Rect(
|
||||
(dst.Bounds().Dx()-w)/2,
|
||||
(dst.Bounds().Dy()-h)/2,
|
||||
(dst.Bounds().Dx()+w)/2,
|
||||
(dst.Bounds().Dy()+h)/2,
|
||||
)
|
||||
scaled := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
|
||||
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), src, srcBounds, xdraw.Src, nil)
|
||||
if tintWhite {
|
||||
tintWhitePremultiplied(scaled)
|
||||
}
|
||||
stddraw.Draw(dst, rect, scaled, image.Point{}, stddraw.Over)
|
||||
}
|
||||
|
||||
// tintWhitePremultiplied 把贴图就地染成白色剪影(保留 alpha 形状)。image.RGBA 是
|
||||
// alpha 预乘存储,分量必须满足 R,G,B ≤ A:若写入 R=G=B=255 而 A<255 的非法值,
|
||||
// draw.Over 合成会算术溢出回绕,凡 alpha 不恰为 255 的像素整片输出近黑色。
|
||||
func tintWhitePremultiplied(img *image.RGBA) {
|
||||
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ {
|
||||
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
|
||||
_, _, _, a := img.At(x, y).RGBA()
|
||||
v := uint8(a >> 8)
|
||||
img.SetRGBA(x, y, color.RGBA{R: v, G: v, B: v, A: v})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func rgbColor(v int) color.RGBA {
|
||||
u := uint32(v)
|
||||
return color.RGBA{R: uint8(u >> 16), G: uint8(u >> 8), B: uint8(u), A: 255}
|
||||
}
|
||||
|
||||
func lerpColor(a, b color.RGBA, t float64) color.RGBA {
|
||||
lerp := func(x, y uint8) uint8 {
|
||||
return uint8(float64(x)*(1-t) + float64(y)*t)
|
||||
}
|
||||
return color.RGBA{R: lerp(a.R, b.R), G: lerp(a.G, b.G), B: lerp(a.B, b.B), A: 255}
|
||||
}
|
||||
|
||||
func imageMimeType(data []byte) string {
|
||||
if len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
|
||||
return "image/png"
|
||||
}
|
||||
if len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff {
|
||||
return "image/jpeg"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func avatarStillMarkup(sizes []domain.PhotoSize) domain.PhotoSize {
|
||||
for _, size := range sizes {
|
||||
if size.Kind == domain.PhotoSizeKindVideoEmojiMarkup || size.Kind == domain.PhotoSizeKindVideoStickerMarkup {
|
||||
return size
|
||||
}
|
||||
if len(size.BackgroundColors) > 0 {
|
||||
return domain.PhotoSize{BackgroundColors: append([]int(nil), size.BackgroundColors...)}
|
||||
}
|
||||
}
|
||||
return domain.PhotoSize{}
|
||||
}
|
||||
|
||||
func randomID() int64 {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue